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/03/18 08:46:43 UTC

(iggy) 01/01: feat(rust): add wire types for all commands and zero-copy message primitives

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

hgruszecki pushed a commit to branch refactor-binary-3-all-commands
in repository https://gitbox.apache.org/repos/asf/iggy.git

commit af4eeb6b12fc06df913eab24d99c8f712f37b410
Author: Hubert Gruszecki <h....@gmail.com>
AuthorDate: Mon Mar 16 19:06:56 2026 +0100

    feat(rust): add wire types for all commands and zero-copy message primitives
    
    The binary_protocol crate had wire types for streams only. This
    adds the remaining 41 commands plus shared primitives and
    zero-copy message frame types.
    
    Shared primitives: WireConsumer, WirePartitioning,
    WirePollingStrategy, WirePermissions (full hierarchy).
    
    Requests: all 10 categories (consumer_groups, consumer_offsets,
    messages, partitions, personal_access_tokens, segments, system,
    topics, users) with WireEncode + WireDecode + roundtrip tests.
    
    Responses: all 9 categories (clients, consumer_groups,
    consumer_offsets, messages, personal_access_tokens, system,
    topics, users) including StatsResponse with CacheMetricsEntry.
    
    Zero-copy message primitives: SendMessagesHeader (metadata-only
    decoder), SendMessagesMetadataEncoder (vectored I/O),
    RawMessage::encode_header, WireMessageView/Mut (frame views
    with typed setters), WireMessageIterator/Mut (frame iterators),
    PollMessagesResponseHeader + PollMessagesResponse (borrowed
    response with lazy iteration), 8 field offset constants.
---
 .typos.toml                                        |   4 +-
 core/binary_protocol/src/codec.rs                  |  52 ++
 core/binary_protocol/src/consensus/header.rs       |  20 -
 core/binary_protocol/src/consensus/message.rs      |   2 +-
 core/binary_protocol/src/frame.rs                  |   6 +
 core/binary_protocol/src/lib.rs                    |  15 +-
 core/binary_protocol/src/message_layout.rs         |  56 ++
 core/binary_protocol/src/message_view.rs           | 614 +++++++++++++++++
 core/binary_protocol/src/primitives/consumer.rs    | 133 ++++
 .../src/{ => primitives}/identifier.rs             |   6 -
 .../src/{requests => primitives}/mod.rs            |   8 +-
 .../binary_protocol/src/primitives/partitioning.rs | 271 ++++++++
 core/binary_protocol/src/primitives/permissions.rs | 471 +++++++++++++
 .../src/primitives/polling_strategy.rs             | 196 ++++++
 .../consumer_groups/create_consumer_group.rs       | 126 ++++
 .../consumer_groups/delete_consumer_group.rs       | 110 +++
 .../requests/consumer_groups/get_consumer_group.rs | 110 +++
 .../get_consumer_groups.rs}                        |  61 +-
 .../consumer_groups/join_consumer_group.rs         | 110 +++
 .../consumer_groups/leave_consumer_group.rs        | 110 +++
 .../{responses => requests/consumer_groups}/mod.rs |  34 +-
 .../consumer_offsets/delete_consumer_offset.rs     | 175 +++++
 .../consumer_offsets/get_consumer_offset.rs        | 175 +++++
 .../src/requests/{ => consumer_offsets}/mod.rs     |   8 +-
 .../consumer_offsets/store_consumer_offset.rs      | 180 +++++
 .../src/requests/messages/flush_unsaved_buffer.rs  | 155 +++++
 .../src/requests/{ => messages}/mod.rs             |  10 +-
 .../src/requests/messages/poll_messages.rs         | 220 ++++++
 .../src/requests/messages/send_messages.rs         | 750 +++++++++++++++++++++
 core/binary_protocol/src/requests/mod.rs           |   9 +
 .../src/requests/partitions/create_partitions.rs   | 118 ++++
 .../src/requests/partitions/delete_partitions.rs   | 118 ++++
 .../src/requests/{ => partitions}/mod.rs           |   6 +-
 .../create_personal_access_token.rs                | 115 ++++
 .../delete_personal_access_token.rs}               |  46 +-
 .../get_personal_access_tokens.rs}                 |  25 +-
 .../login_with_personal_access_token.rs}           |  47 +-
 .../personal_access_tokens/mod.rs}                 |  16 +-
 .../src/requests/segments/delete_segments.rs       | 128 ++++
 .../src/requests/{ => segments}/mod.rs             |   4 +-
 .../src/requests/streams/create_stream.rs          |   2 +-
 .../src/requests/streams/update_stream.rs          |   2 +-
 .../create_stream.rs => system/get_client.rs}      |  72 +-
 .../mod.rs => requests/system/get_clients.rs}      |  25 +-
 .../system/get_cluster_metadata.rs}                |  25 +-
 .../mod.rs => requests/system/get_me.rs}           |  25 +-
 .../mod.rs => requests/system/get_snapshot.rs}     |  25 +-
 .../mod.rs => requests/system/get_stats.rs}        |  25 +-
 .../src/{frame.rs => requests/system/mod.rs}       |  22 +-
 .../{responses/mod.rs => requests/system/ping.rs}  |  25 +-
 .../src/requests/topics/create_topic.rs            | 147 ++++
 .../update_stream.rs => topics/delete_topic.rs}    |  51 +-
 .../update_stream.rs => topics/get_topic.rs}       |  53 +-
 .../update_stream.rs => topics/get_topics.rs}      |  44 +-
 .../src/{frame.rs => requests/topics/mod.rs}       |  20 +-
 .../update_stream.rs => topics/purge_topic.rs}     |  39 +-
 .../src/requests/topics/update_topic.rs            | 150 +++++
 .../src/requests/users/change_password.rs          | 131 ++++
 .../src/requests/users/create_user.rs              | 203 ++++++
 .../update_stream.rs => users/delete_user.rs}      |  48 +-
 .../update_stream.rs => users/get_user.rs}         |  57 +-
 .../mod.rs => requests/users/get_users.rs}         |  25 +-
 .../src/requests/users/login_user.rs               | 220 ++++++
 .../mod.rs => requests/users/logout_user.rs}       |  25 +-
 .../src/{responses => requests/users}/mod.rs       |  40 +-
 .../src/requests/users/update_permissions.rs       | 160 +++++
 .../src/requests/users/update_user.rs              | 184 +++++
 .../src/responses/clients/client_response.rs       | 211 ++++++
 .../src/responses/clients/get_client.rs            | 143 ++++
 .../src/responses/clients/get_clients.rs           |  98 +++
 .../src/{requests => responses/clients}/mod.rs     |   8 +-
 .../consumer_group_response.rs}                    |  86 ++-
 .../consumer_groups/create_consumer_group.rs}      |   3 +-
 .../consumer_groups/delete_consumer_group.rs}      |   3 +-
 .../consumer_groups/get_consumer_group.rs          | 210 ++++++
 .../consumer_groups/get_consumer_groups.rs         | 109 +++
 .../consumer_groups/join_consumer_group.rs}        |   3 +-
 .../consumer_groups/leave_consumer_group.rs}       |   3 +-
 .../src/responses/{ => consumer_groups}/mod.rs     |  37 +-
 .../consumer_offsets/delete_consumer_offset.rs}    |   3 +-
 .../consumer_offsets/get_consumer_offset.rs        | 114 ++++
 .../consumer_offsets}/mod.rs                       |   9 +-
 .../consumer_offsets/store_consumer_offset.rs}     |   3 +-
 .../src/{requests => responses/messages}/mod.rs    |   4 +-
 .../src/responses/messages/poll_messages.rs        | 176 +++++
 core/binary_protocol/src/responses/mod.rs          |   8 +
 .../create_personal_access_token.rs}               |  51 +-
 .../delete_personal_access_token.rs}               |   2 +-
 .../get_personal_access_tokens.rs                  | 145 ++++
 .../login_with_personal_access_token.rs}           |   2 +-
 .../responses/{ => personal_access_tokens}/mod.rs  |  33 +-
 .../src/responses/streams/get_stream.rs            |   2 +-
 .../src/responses/streams/stream_response.rs       |   2 +-
 .../src/responses/system/get_stats.rs              | 427 ++++++++++++
 .../src/{requests => responses/system}/mod.rs      |   7 +-
 .../{requests/mod.rs => responses/system/ping.rs}  |   2 +-
 .../mod.rs => responses/topics/create_topic.rs}    |   3 +-
 .../mod.rs => responses/topics/delete_topic.rs}    |   3 +-
 .../src/responses/topics/get_topic.rs              | 234 +++++++
 .../src/responses/topics/get_topics.rs             | 115 ++++
 .../src/{frame.rs => responses/topics/mod.rs}      |  21 +-
 .../mod.rs => responses/topics/purge_topic.rs}     |   3 +-
 .../mod.rs => responses/topics/update_topic.rs}    |   3 +-
 .../mod.rs => responses/users/change_password.rs}  |   2 +-
 .../mod.rs => responses/users/create_user.rs}      |   2 +-
 .../mod.rs => responses/users/delete_user.rs}      |   2 +-
 .../src/responses/users/get_user.rs                | 205 ++++++
 .../src/responses/users/get_users.rs               |  97 +++
 .../src/responses/users/login_user.rs              |  75 +++
 .../mod.rs => responses/users/logout_user.rs}      |   2 +-
 .../src/responses/{ => users}/mod.rs               |  43 +-
 .../users/update_permissions.rs}                   |   2 +-
 .../mod.rs => responses/users/update_user.rs}      |   2 +-
 .../stream_response.rs => users/user_response.rs}  |  86 ++-
 114 files changed, 8841 insertions(+), 628 deletions(-)

diff --git a/.typos.toml b/.typos.toml
index dba98da09..7550e6f15 100644
--- a/.typos.toml
+++ b/.typos.toml
@@ -17,6 +17,8 @@
 
 # Whitelist valid technical terms to avoid typos false positives
 [default.extend-words]
+# French for coffee, used in UTF-8 test strings (cafe with accent)
+caf = "caf"
 # Valid bi-directional map data structure term
 bimap = "bimap"
 # Valid compression algorithm abbreviation
@@ -29,8 +31,6 @@ bais = "bais"
 Strat = "Strat"
 # Same as above
 strin = "strin"
-# Valid prefix in Unicode escape test strings (e.g. "caf\u{00e9}" = cafe)
-caf = "caf"
 
 # Exclude auto-generated/non-editable files from typos check
 [files]
diff --git a/core/binary_protocol/src/codec.rs b/core/binary_protocol/src/codec.rs
index 3aedd9c5e..1ef155847 100644
--- a/core/binary_protocol/src/codec.rs
+++ b/core/binary_protocol/src/codec.rs
@@ -152,6 +152,58 @@ pub fn read_str(buf: &[u8], offset: usize, len: usize) -> Result<String, WireErr
         .map_err(|_| WireError::InvalidUtf8 { offset })
 }
 
+/// Helper to read a `u128` LE from `buf` at `offset`.
+///
+/// # Errors
+/// Returns `WireError::UnexpectedEof` if fewer than 16 bytes remain.
+#[allow(clippy::missing_panics_doc)]
+#[inline]
+pub fn read_u128_le(buf: &[u8], offset: usize) -> Result<u128, WireError> {
+    let end = offset
+        .checked_add(16)
+        .ok_or_else(|| WireError::UnexpectedEof {
+            offset,
+            need: 16,
+            have: buf.len().saturating_sub(offset),
+        })?;
+    let slice = buf
+        .get(offset..end)
+        .ok_or_else(|| WireError::UnexpectedEof {
+            offset,
+            need: 16,
+            have: buf.len().saturating_sub(offset),
+        })?;
+    Ok(u128::from_le_bytes(
+        slice.try_into().expect("slice is exactly 16 bytes"),
+    ))
+}
+
+/// Helper to read an `f32` LE from `buf` at `offset`.
+///
+/// # Errors
+/// Returns `WireError::UnexpectedEof` if fewer than 4 bytes remain.
+#[allow(clippy::missing_panics_doc)]
+#[inline]
+pub fn read_f32_le(buf: &[u8], offset: usize) -> Result<f32, WireError> {
+    let end = offset
+        .checked_add(4)
+        .ok_or_else(|| WireError::UnexpectedEof {
+            offset,
+            need: 4,
+            have: buf.len().saturating_sub(offset),
+        })?;
+    let slice = buf
+        .get(offset..end)
+        .ok_or_else(|| WireError::UnexpectedEof {
+            offset,
+            need: 4,
+            have: buf.len().saturating_sub(offset),
+        })?;
+    Ok(f32::from_le_bytes(
+        slice.try_into().expect("slice is exactly 4 bytes"),
+    ))
+}
+
 /// Helper to read a byte slice of `len` bytes from `buf` at `offset`.
 ///
 /// # Errors
diff --git a/core/binary_protocol/src/consensus/header.rs b/core/binary_protocol/src/consensus/header.rs
index 648aae6ef..40f66b1e0 100644
--- a/core/binary_protocol/src/consensus/header.rs
+++ b/core/binary_protocol/src/consensus/header.rs
@@ -40,9 +40,7 @@ pub trait ConsensusHeader: Sized + CheckedBitPattern + NoUninit {
     fn size(&self) -> u32;
 }
 
-// ---------------------------------------------------------------------------
 // GenericHeader - type-erased dispatch
-// ---------------------------------------------------------------------------
 
 /// Type-erased 256-byte header for initial message dispatch.
 #[repr(C)]
@@ -84,9 +82,7 @@ impl ConsensusHeader for GenericHeader {
     }
 }
 
-// ---------------------------------------------------------------------------
 // RequestHeader - client -> primary
-// ---------------------------------------------------------------------------
 
 /// Client -> primary request header. 256 bytes.
 #[repr(C)]
@@ -167,9 +163,7 @@ impl ConsensusHeader for RequestHeader {
     }
 }
 
-// ---------------------------------------------------------------------------
 // ReplyHeader - primary -> client
-// ---------------------------------------------------------------------------
 
 /// Primary -> client reply header. 256 bytes.
 #[repr(C)]
@@ -251,9 +245,7 @@ impl ConsensusHeader for ReplyHeader {
     }
 }
 
-// ---------------------------------------------------------------------------
 // PrepareHeader - primary -> replicas (replication)
-// ---------------------------------------------------------------------------
 
 /// Primary -> replicas: replicate this operation.
 #[repr(C)]
@@ -340,9 +332,7 @@ impl ConsensusHeader for PrepareHeader {
     }
 }
 
-// ---------------------------------------------------------------------------
 // PrepareOkHeader - replica -> primary (acknowledgement)
-// ---------------------------------------------------------------------------
 
 /// Replica -> primary: acknowledge a Prepare.
 #[repr(C)]
@@ -427,9 +417,7 @@ impl ConsensusHeader for PrepareOkHeader {
     }
 }
 
-// ---------------------------------------------------------------------------
 // CommitHeader - primary -> replicas (commit, header-only)
-// ---------------------------------------------------------------------------
 
 /// Primary -> replicas: commit up to this op. Header-only (no body).
 #[repr(C)]
@@ -484,9 +472,7 @@ impl ConsensusHeader for CommitHeader {
     }
 }
 
-// ---------------------------------------------------------------------------
 // StartViewChangeHeader - failure detection (header-only)
-// ---------------------------------------------------------------------------
 
 /// Replica suspects primary failure. Header-only.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, CheckedBitPattern, NoUninit)]
@@ -540,9 +526,7 @@ impl ConsensusHeader for StartViewChangeHeader {
     }
 }
 
-// ---------------------------------------------------------------------------
 // DoViewChangeHeader - view change vote (header-only)
-// ---------------------------------------------------------------------------
 
 /// Replica -> primary candidate: vote for view change. Header-only.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, CheckedBitPattern, NoUninit)]
@@ -614,9 +598,7 @@ impl ConsensusHeader for DoViewChangeHeader {
     }
 }
 
-// ---------------------------------------------------------------------------
 // StartViewHeader - new view announcement (header-only)
-// ---------------------------------------------------------------------------
 
 /// New primary -> all replicas: start new view. Header-only.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, CheckedBitPattern, NoUninit)]
@@ -681,9 +663,7 @@ impl ConsensusHeader for StartViewHeader {
     }
 }
 
-// ---------------------------------------------------------------------------
 // Tests
-// ---------------------------------------------------------------------------
 
 #[cfg(test)]
 mod tests {
diff --git a/core/binary_protocol/src/consensus/message.rs b/core/binary_protocol/src/consensus/message.rs
index ee2ac4be3..d8f56d872 100644
--- a/core/binary_protocol/src/consensus/message.rs
+++ b/core/binary_protocol/src/consensus/message.rs
@@ -49,7 +49,7 @@ impl<H: ConsensusHeader> Message<H> {
             });
         }
 
-        // TODO: bytemuck::checked::try_from_bytes requires the buffer to be aligned
+        // TODO(hubcio): bytemuck::checked::try_from_bytes requires the buffer to be aligned
         // to the target type's alignment. Consensus headers contain u128 fields (16-byte
         // alignment), but Bytes from Vec<u8> only guarantees 8-byte alignment. The checked
         // variant returns Err on misalignment (no UB), but production code can fail on
diff --git a/core/binary_protocol/src/frame.rs b/core/binary_protocol/src/frame.rs
index b17d0d3f9..77a5f2b3e 100644
--- a/core/binary_protocol/src/frame.rs
+++ b/core/binary_protocol/src/frame.rs
@@ -15,6 +15,12 @@
 // specific language governing permissions and limitations
 // under the License.
 
+// TODO(hubcio): Legacy framing constants for the current binary protocol.
+// Once VSR consensus is integrated, both client-server and
+// replica-replica traffic will use the unified 256-byte
+// consensus header (`consensus::header::HEADER_SIZE`).
+// These constants will be removed at that point.
+
 /// Request frame: `[length:4 LE][code:4 LE][payload:N]`
 /// `length` = size of code + payload = 4 + N
 pub const REQUEST_HEADER_SIZE: usize = 4;
diff --git a/core/binary_protocol/src/lib.rs b/core/binary_protocol/src/lib.rs
index 288d468a5..9610abf7e 100644
--- a/core/binary_protocol/src/lib.rs
+++ b/core/binary_protocol/src/lib.rs
@@ -23,7 +23,7 @@
 //!
 //! # Design
 //!
-//! Protocol types are independent from domain types (`iggy_common`).
+//! Protocol types are independent from domain types.
 //! Conversion between wire types and domain types happens at the boundary
 //! (SDK client impls, server handlers).
 //!
@@ -47,7 +47,9 @@ pub mod codes;
 pub mod consensus;
 pub mod error;
 pub mod frame;
-pub mod identifier;
+pub mod message_layout;
+pub mod message_view;
+pub mod primitives;
 pub mod requests;
 pub mod responses;
 
@@ -55,4 +57,11 @@ pub use codec::{WireDecode, WireEncode};
 pub use codes::*;
 pub use error::WireError;
 pub use frame::*;
-pub use identifier::{MAX_WIRE_NAME_LENGTH, WireIdentifier, WireName};
+pub use message_layout::*;
+pub use primitives::consumer::WireConsumer;
+pub use primitives::identifier::{MAX_WIRE_NAME_LENGTH, WireIdentifier, WireName};
+pub use primitives::partitioning::{MAX_MESSAGES_KEY_LENGTH, WirePartitioning};
+pub use primitives::permissions::{
+    WireGlobalPermissions, WirePermissions, WireStreamPermissions, WireTopicPermissions,
+};
+pub use primitives::polling_strategy::WirePollingStrategy;
diff --git a/core/binary_protocol/src/message_layout.rs b/core/binary_protocol/src/message_layout.rs
new file mode 100644
index 000000000..2858ec5e2
--- /dev/null
+++ b/core/binary_protocol/src/message_layout.rs
@@ -0,0 +1,56 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! 64-byte message frame layout constants.
+//!
+//! This module is the single source of truth for the on-wire message
+//! header layout shared between encoders, decoders, and zero-copy views.
+//!
+//! ```text
+//! [checksum:8][id:16][offset:8][timestamp:8][origin_timestamp:8]
+//! [user_headers_length:4][payload_length:4][reserved:8]
+//! ```
+
+/// Fixed-size message header on the wire (64 bytes).
+pub const WIRE_MESSAGE_HEADER_SIZE: usize = 64;
+
+/// Fixed-size index entry per message (16 bytes).
+///
+/// Layout: `[0x00000000:4][cumulative_size:u32_le:4][0x0000000000000000:8]`
+pub const WIRE_MESSAGE_INDEX_SIZE: usize = 16;
+
+// Field offsets within the 64-byte message header.
+pub const MSG_CHECKSUM_OFFSET: usize = 0;
+pub const MSG_ID_OFFSET: usize = 8;
+pub const MSG_OFFSET_OFFSET: usize = 24;
+pub const MSG_TIMESTAMP_OFFSET: usize = 32;
+pub const MSG_ORIGIN_TIMESTAMP_OFFSET: usize = 40;
+pub const MSG_USER_HEADERS_LEN_OFFSET: usize = 48;
+pub const MSG_PAYLOAD_LEN_OFFSET: usize = 52;
+pub const MSG_RESERVED_OFFSET: usize = 56;
+
+// Compile-time verification that field offsets are contiguous and sum to the header size.
+const _: () = {
+    assert!(MSG_ID_OFFSET == MSG_CHECKSUM_OFFSET + 8);
+    assert!(MSG_OFFSET_OFFSET == MSG_ID_OFFSET + 16);
+    assert!(MSG_TIMESTAMP_OFFSET == MSG_OFFSET_OFFSET + 8);
+    assert!(MSG_ORIGIN_TIMESTAMP_OFFSET == MSG_TIMESTAMP_OFFSET + 8);
+    assert!(MSG_USER_HEADERS_LEN_OFFSET == MSG_ORIGIN_TIMESTAMP_OFFSET + 8);
+    assert!(MSG_PAYLOAD_LEN_OFFSET == MSG_USER_HEADERS_LEN_OFFSET + 4);
+    assert!(MSG_RESERVED_OFFSET == MSG_PAYLOAD_LEN_OFFSET + 4);
+    assert!(MSG_RESERVED_OFFSET + 8 == WIRE_MESSAGE_HEADER_SIZE);
+};
diff --git a/core/binary_protocol/src/message_view.rs b/core/binary_protocol/src/message_view.rs
new file mode 100644
index 000000000..c291d9a7e
--- /dev/null
+++ b/core/binary_protocol/src/message_view.rs
@@ -0,0 +1,614 @@
+// 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.
+
+//! Zero-copy view types for message frames on the wire.
+//!
+//! These types borrow an existing buffer and provide typed access to
+//! message header fields and payload data without copying.
+
+use crate::error::WireError;
+use crate::message_layout::{
+    MSG_CHECKSUM_OFFSET, MSG_ID_OFFSET, MSG_OFFSET_OFFSET, MSG_ORIGIN_TIMESTAMP_OFFSET,
+    MSG_PAYLOAD_LEN_OFFSET, MSG_TIMESTAMP_OFFSET, MSG_USER_HEADERS_LEN_OFFSET,
+    WIRE_MESSAGE_HEADER_SIZE,
+};
+
+// Private helpers for infallible reads on validated buffers
+
+#[inline]
+fn u32_at(buf: &[u8], offset: usize) -> u32 {
+    u32::from_le_bytes(
+        buf[offset..offset + 4]
+            .try_into()
+            .expect("slice is exactly 4 bytes"),
+    )
+}
+
+#[inline]
+fn u64_at(buf: &[u8], offset: usize) -> u64 {
+    u64::from_le_bytes(
+        buf[offset..offset + 8]
+            .try_into()
+            .expect("slice is exactly 8 bytes"),
+    )
+}
+
+#[inline]
+fn u128_at(buf: &[u8], offset: usize) -> u128 {
+    u128::from_le_bytes(
+        buf[offset..offset + 16]
+            .try_into()
+            .expect("slice is exactly 16 bytes"),
+    )
+}
+
+/// Validate a message frame buffer and return `(total_size, payload_len, user_headers_len)`.
+fn validate_frame(buf: &[u8]) -> Result<(usize, usize, usize), WireError> {
+    if buf.len() < WIRE_MESSAGE_HEADER_SIZE {
+        return Err(WireError::UnexpectedEof {
+            offset: 0,
+            need: WIRE_MESSAGE_HEADER_SIZE,
+            have: buf.len(),
+        });
+    }
+
+    let user_headers_len = u32_at(buf, MSG_USER_HEADERS_LEN_OFFSET) as usize;
+    let payload_len = u32_at(buf, MSG_PAYLOAD_LEN_OFFSET) as usize;
+
+    let total = WIRE_MESSAGE_HEADER_SIZE
+        .checked_add(payload_len)
+        .and_then(|s| s.checked_add(user_headers_len))
+        .ok_or_else(|| WireError::Validation("message frame size overflow".to_string()))?;
+
+    if buf.len() < total {
+        return Err(WireError::UnexpectedEof {
+            offset: 0,
+            need: total,
+            have: buf.len(),
+        });
+    }
+
+    Ok((total, payload_len, user_headers_len))
+}
+
+// WireMessageView - immutable zero-copy frame view
+
+/// Borrowed view over a single message frame in a contiguous buffer.
+///
+/// Validates the buffer on construction; all accessors are infallible.
+pub struct WireMessageView<'a> {
+    buf: &'a [u8],
+    payload_len: usize,
+    user_headers_len: usize,
+}
+
+impl<'a> WireMessageView<'a> {
+    /// Create a view over the first message frame in `buf`.
+    ///
+    /// # Errors
+    /// Returns `WireError` if the buffer is shorter than the header (64 bytes)
+    /// or shorter than the full frame indicated by the length fields.
+    pub fn new(buf: &'a [u8]) -> Result<Self, WireError> {
+        let (total, payload_len, user_headers_len) = validate_frame(buf)?;
+        Ok(Self {
+            buf: &buf[..total],
+            payload_len,
+            user_headers_len,
+        })
+    }
+
+    #[must_use]
+    #[inline]
+    pub fn checksum(&self) -> u64 {
+        u64_at(self.buf, MSG_CHECKSUM_OFFSET)
+    }
+
+    #[must_use]
+    #[inline]
+    pub fn id(&self) -> u128 {
+        u128_at(self.buf, MSG_ID_OFFSET)
+    }
+
+    #[must_use]
+    #[inline]
+    pub fn offset(&self) -> u64 {
+        u64_at(self.buf, MSG_OFFSET_OFFSET)
+    }
+
+    #[must_use]
+    #[inline]
+    pub fn timestamp(&self) -> u64 {
+        u64_at(self.buf, MSG_TIMESTAMP_OFFSET)
+    }
+
+    #[must_use]
+    #[inline]
+    pub fn origin_timestamp(&self) -> u64 {
+        u64_at(self.buf, MSG_ORIGIN_TIMESTAMP_OFFSET)
+    }
+
+    #[must_use]
+    #[inline]
+    pub const fn payload_length(&self) -> usize {
+        self.payload_len
+    }
+
+    #[must_use]
+    #[inline]
+    pub const fn user_headers_length(&self) -> usize {
+        self.user_headers_len
+    }
+
+    #[must_use]
+    pub fn payload(&self) -> &'a [u8] {
+        &self.buf[WIRE_MESSAGE_HEADER_SIZE..WIRE_MESSAGE_HEADER_SIZE + self.payload_len]
+    }
+
+    #[must_use]
+    pub fn user_headers(&self) -> &'a [u8] {
+        let start = WIRE_MESSAGE_HEADER_SIZE + self.payload_len;
+        &self.buf[start..start + self.user_headers_len]
+    }
+
+    #[must_use]
+    #[inline]
+    pub const fn total_size(&self) -> usize {
+        self.buf.len()
+    }
+
+    #[must_use]
+    pub const fn as_bytes(&self) -> &'a [u8] {
+        self.buf
+    }
+}
+
+// WireMessageViewMut - mutable zero-copy frame view
+
+/// Mutable view for in-place header patching (offset, timestamp, checksum).
+pub struct WireMessageViewMut<'a> {
+    buf: &'a mut [u8],
+    payload_len: usize,
+    user_headers_len: usize,
+}
+
+impl<'a> WireMessageViewMut<'a> {
+    /// Create a mutable view over the first message frame in `buf`.
+    ///
+    /// # Errors
+    /// Same validation as [`WireMessageView::new`].
+    pub fn new(buf: &'a mut [u8]) -> Result<Self, WireError> {
+        let (total, payload_len, user_headers_len) = validate_frame(buf)?;
+        let (frame, _) = buf.split_at_mut(total);
+        Ok(Self {
+            buf: frame,
+            payload_len,
+            user_headers_len,
+        })
+    }
+
+    // -- Read accessors (same semantics as WireMessageView) --
+
+    #[must_use]
+    #[inline]
+    pub fn checksum(&self) -> u64 {
+        u64_at(self.buf, MSG_CHECKSUM_OFFSET)
+    }
+
+    #[must_use]
+    #[inline]
+    pub fn id(&self) -> u128 {
+        u128_at(self.buf, MSG_ID_OFFSET)
+    }
+
+    #[must_use]
+    #[inline]
+    pub fn offset(&self) -> u64 {
+        u64_at(self.buf, MSG_OFFSET_OFFSET)
+    }
+
+    #[must_use]
+    #[inline]
+    pub fn timestamp(&self) -> u64 {
+        u64_at(self.buf, MSG_TIMESTAMP_OFFSET)
+    }
+
+    #[must_use]
+    #[inline]
+    pub fn origin_timestamp(&self) -> u64 {
+        u64_at(self.buf, MSG_ORIGIN_TIMESTAMP_OFFSET)
+    }
+
+    #[must_use]
+    #[inline]
+    pub const fn payload_length(&self) -> usize {
+        self.payload_len
+    }
+
+    #[must_use]
+    #[inline]
+    pub const fn user_headers_length(&self) -> usize {
+        self.user_headers_len
+    }
+
+    #[must_use]
+    pub fn payload(&self) -> &[u8] {
+        &self.buf[WIRE_MESSAGE_HEADER_SIZE..WIRE_MESSAGE_HEADER_SIZE + self.payload_len]
+    }
+
+    #[must_use]
+    pub fn user_headers(&self) -> &[u8] {
+        let start = WIRE_MESSAGE_HEADER_SIZE + self.payload_len;
+        &self.buf[start..start + self.user_headers_len]
+    }
+
+    #[must_use]
+    #[inline]
+    pub const fn total_size(&self) -> usize {
+        self.buf.len()
+    }
+
+    // -- Typed setters --
+
+    pub fn set_checksum(&mut self, value: u64) {
+        self.buf[MSG_CHECKSUM_OFFSET..MSG_CHECKSUM_OFFSET + 8]
+            .copy_from_slice(&value.to_le_bytes());
+    }
+
+    pub fn set_id(&mut self, value: u128) {
+        self.buf[MSG_ID_OFFSET..MSG_ID_OFFSET + 16].copy_from_slice(&value.to_le_bytes());
+    }
+
+    pub fn set_offset(&mut self, value: u64) {
+        self.buf[MSG_OFFSET_OFFSET..MSG_OFFSET_OFFSET + 8].copy_from_slice(&value.to_le_bytes());
+    }
+
+    pub fn set_timestamp(&mut self, value: u64) {
+        self.buf[MSG_TIMESTAMP_OFFSET..MSG_TIMESTAMP_OFFSET + 8]
+            .copy_from_slice(&value.to_le_bytes());
+    }
+}
+
+// WireMessageIterator - zero-copy frame iterator
+
+/// Iterates over contiguous message frames, yielding borrowed views.
+pub struct WireMessageIterator<'a> {
+    buf: &'a [u8],
+    pos: usize,
+    remaining: u32,
+}
+
+impl<'a> WireMessageIterator<'a> {
+    #[must_use]
+    pub const fn new(buf: &'a [u8], count: u32) -> Self {
+        Self {
+            buf,
+            pos: 0,
+            remaining: count,
+        }
+    }
+}
+
+impl<'a> Iterator for WireMessageIterator<'a> {
+    type Item = Result<WireMessageView<'a>, WireError>;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        if self.remaining == 0 {
+            return None;
+        }
+        match WireMessageView::new(&self.buf[self.pos..]) {
+            Ok(view) => {
+                self.pos += view.total_size();
+                self.remaining -= 1;
+                Some(Ok(view))
+            }
+            Err(e) => {
+                self.remaining = 0;
+                Some(Err(e))
+            }
+        }
+    }
+}
+
+// WireMessageIteratorMut - mutable frame iterator (cursor-based)
+
+/// Mutable iterator over contiguous message frames.
+///
+/// Cannot implement standard `Iterator` because the yielded mutable view's
+/// lifetime is tied to `&mut self`. Uses a cursor-based API instead.
+pub struct WireMessageIteratorMut<'a> {
+    buf: &'a mut [u8],
+    pos: usize,
+    remaining: u32,
+}
+
+impl<'a> WireMessageIteratorMut<'a> {
+    pub const fn new(buf: &'a mut [u8], count: u32) -> Self {
+        Self {
+            buf,
+            pos: 0,
+            remaining: count,
+        }
+    }
+
+    /// Advance to the next frame and return a mutable view over it.
+    ///
+    /// The returned view borrows `self`, so it must be dropped before
+    /// calling this method again.
+    pub fn next_view_mut(&mut self) -> Option<Result<WireMessageViewMut<'_>, WireError>> {
+        if self.remaining == 0 {
+            return None;
+        }
+
+        // Compute frame size via temporary immutable access (borrow ends at block exit).
+        let (total, available) = {
+            let rest = &self.buf[self.pos..];
+            if rest.len() < WIRE_MESSAGE_HEADER_SIZE {
+                self.remaining = 0;
+                return Some(Err(WireError::UnexpectedEof {
+                    offset: self.pos,
+                    need: WIRE_MESSAGE_HEADER_SIZE,
+                    have: rest.len(),
+                }));
+            }
+            let user_headers_len = u32_at(rest, MSG_USER_HEADERS_LEN_OFFSET) as usize;
+            let payload_len = u32_at(rest, MSG_PAYLOAD_LEN_OFFSET) as usize;
+            let Some(total) = WIRE_MESSAGE_HEADER_SIZE
+                .checked_add(payload_len)
+                .and_then(|s| s.checked_add(user_headers_len))
+            else {
+                self.remaining = 0;
+                return Some(Err(WireError::Validation(
+                    "message frame size overflow".to_string(),
+                )));
+            };
+            (total, rest.len())
+        };
+
+        if available < total {
+            self.remaining = 0;
+            return Some(Err(WireError::UnexpectedEof {
+                offset: self.pos,
+                need: total,
+                have: available,
+            }));
+        }
+
+        let start = self.pos;
+        self.pos += total;
+        self.remaining -= 1;
+
+        Some(WireMessageViewMut::new(&mut self.buf[start..start + total]))
+    }
+}
+
+// Tests
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::message_layout::{
+        MSG_ID_OFFSET, MSG_ORIGIN_TIMESTAMP_OFFSET, MSG_PAYLOAD_LEN_OFFSET,
+        MSG_USER_HEADERS_LEN_OFFSET, WIRE_MESSAGE_HEADER_SIZE,
+    };
+
+    fn make_frame(payload: &[u8], user_headers: &[u8], id: u128, origin_ts: u64) -> Vec<u8> {
+        let total = WIRE_MESSAGE_HEADER_SIZE + payload.len() + user_headers.len();
+        let mut frame = vec![0u8; total];
+        frame[MSG_ID_OFFSET..MSG_ID_OFFSET + 16].copy_from_slice(&id.to_le_bytes());
+        frame[MSG_ORIGIN_TIMESTAMP_OFFSET..MSG_ORIGIN_TIMESTAMP_OFFSET + 8]
+            .copy_from_slice(&origin_ts.to_le_bytes());
+        #[allow(clippy::cast_possible_truncation)]
+        {
+            frame[MSG_USER_HEADERS_LEN_OFFSET..MSG_USER_HEADERS_LEN_OFFSET + 4]
+                .copy_from_slice(&(user_headers.len() as u32).to_le_bytes());
+            frame[MSG_PAYLOAD_LEN_OFFSET..MSG_PAYLOAD_LEN_OFFSET + 4]
+                .copy_from_slice(&(payload.len() as u32).to_le_bytes());
+        }
+        frame[WIRE_MESSAGE_HEADER_SIZE..WIRE_MESSAGE_HEADER_SIZE + payload.len()]
+            .copy_from_slice(payload);
+        frame[WIRE_MESSAGE_HEADER_SIZE + payload.len()..].copy_from_slice(user_headers);
+        frame
+    }
+
+    // -- WireMessageView --
+
+    #[test]
+    fn view_rejects_short_buffer() {
+        let buf = [0u8; WIRE_MESSAGE_HEADER_SIZE - 1];
+        assert!(WireMessageView::new(&buf).is_err());
+    }
+
+    #[test]
+    fn view_rejects_truncated_payload() {
+        let mut buf = vec![0u8; WIRE_MESSAGE_HEADER_SIZE + 5];
+        buf[MSG_PAYLOAD_LEN_OFFSET..MSG_PAYLOAD_LEN_OFFSET + 4]
+            .copy_from_slice(&10u32.to_le_bytes());
+        assert!(WireMessageView::new(&buf).is_err());
+    }
+
+    #[test]
+    fn view_accessors() {
+        let frame = make_frame(b"hello", b"hdr", 42, 999);
+        let view = WireMessageView::new(&frame).unwrap();
+        assert_eq!(view.checksum(), 0);
+        assert_eq!(view.id(), 42);
+        assert_eq!(view.offset(), 0);
+        assert_eq!(view.timestamp(), 0);
+        assert_eq!(view.origin_timestamp(), 999);
+        assert_eq!(view.payload_length(), 5);
+        assert_eq!(view.user_headers_length(), 3);
+        assert_eq!(view.payload(), b"hello");
+        assert_eq!(view.user_headers(), b"hdr");
+        assert_eq!(view.total_size(), WIRE_MESSAGE_HEADER_SIZE + 5 + 3);
+        assert_eq!(view.as_bytes().len(), view.total_size());
+    }
+
+    #[test]
+    fn view_empty_payload_and_headers() {
+        let frame = make_frame(b"", b"", 1, 0);
+        let view = WireMessageView::new(&frame).unwrap();
+        assert_eq!(view.payload(), b"");
+        assert_eq!(view.user_headers(), b"");
+        assert_eq!(view.total_size(), WIRE_MESSAGE_HEADER_SIZE);
+    }
+
+    #[test]
+    fn view_from_larger_buffer() {
+        let mut buf = make_frame(b"pay", b"", 1, 0);
+        buf.extend_from_slice(b"trailing_garbage");
+        let view = WireMessageView::new(&buf).unwrap();
+        assert_eq!(view.total_size(), WIRE_MESSAGE_HEADER_SIZE + 3);
+        assert_eq!(view.as_bytes().len(), WIRE_MESSAGE_HEADER_SIZE + 3);
+    }
+
+    // -- WireMessageViewMut --
+
+    #[test]
+    fn view_mut_set_get_roundtrip() {
+        let mut frame = make_frame(b"data", b"", 1, 0);
+        let mut view = WireMessageViewMut::new(&mut frame).unwrap();
+
+        view.set_checksum(0xDEAD_BEEF);
+        view.set_id(0x1234);
+        view.set_offset(100);
+        view.set_timestamp(200);
+
+        assert_eq!(view.checksum(), 0xDEAD_BEEF);
+        assert_eq!(view.id(), 0x1234);
+        assert_eq!(view.offset(), 100);
+        assert_eq!(view.timestamp(), 200);
+        assert_eq!(view.origin_timestamp(), 0);
+        assert_eq!(view.payload(), b"data");
+    }
+
+    #[test]
+    fn view_mut_set_does_not_corrupt_adjacent_fields() {
+        let mut frame = make_frame(b"x", b"y", 42, 999);
+        {
+            let mut view = WireMessageViewMut::new(&mut frame).unwrap();
+            view.set_offset(0xFFFF_FFFF_FFFF_FFFF);
+        }
+        let view = WireMessageView::new(&frame).unwrap();
+        assert_eq!(view.id(), 42);
+        assert_eq!(view.offset(), 0xFFFF_FFFF_FFFF_FFFF);
+        assert_eq!(view.timestamp(), 0);
+        assert_eq!(view.origin_timestamp(), 999);
+        assert_eq!(view.payload(), b"x");
+        assert_eq!(view.user_headers(), b"y");
+    }
+
+    // -- WireMessageIterator --
+
+    #[test]
+    fn iterator_zero_count() {
+        let buf = [];
+        let mut iter = WireMessageIterator::new(&buf, 0);
+        assert!(iter.next().is_none());
+    }
+
+    #[test]
+    fn iterator_single_message() {
+        let frame = make_frame(b"hello", b"", 1, 100);
+        let mut iter = WireMessageIterator::new(&frame, 1);
+        let view = iter.next().unwrap().unwrap();
+        assert_eq!(view.id(), 1);
+        assert_eq!(view.payload(), b"hello");
+        assert!(iter.next().is_none());
+    }
+
+    #[test]
+    fn iterator_multiple_messages() {
+        let f1 = make_frame(b"aaa", b"", 1, 100);
+        let f2 = make_frame(b"bbb", b"hh", 2, 200);
+        let f3 = make_frame(b"c", b"", 3, 300);
+        let mut buf = Vec::new();
+        buf.extend_from_slice(&f1);
+        buf.extend_from_slice(&f2);
+        buf.extend_from_slice(&f3);
+
+        let views: Vec<_> = WireMessageIterator::new(&buf, 3)
+            .collect::<Result<Vec<_>, _>>()
+            .unwrap();
+        assert_eq!(views.len(), 3);
+        assert_eq!(views[0].id(), 1);
+        assert_eq!(views[0].payload(), b"aaa");
+        assert_eq!(views[1].id(), 2);
+        assert_eq!(views[1].payload(), b"bbb");
+        assert_eq!(views[1].user_headers(), b"hh");
+        assert_eq!(views[2].id(), 3);
+        assert_eq!(views[2].payload(), b"c");
+    }
+
+    #[test]
+    fn iterator_truncated_mid_frame() {
+        let frame = make_frame(b"hello", b"", 1, 0);
+        let truncated = &frame[..frame.len() / 2];
+        let mut iter = WireMessageIterator::new(truncated, 1);
+        assert!(iter.next().unwrap().is_err());
+        assert!(iter.next().is_none());
+    }
+
+    // -- WireMessageIteratorMut --
+
+    #[test]
+    fn iterator_mut_mutate_then_read() {
+        let f1 = make_frame(b"aaa", b"", 1, 100);
+        let f2 = make_frame(b"bbb", b"", 2, 200);
+        let mut buf = Vec::new();
+        buf.extend_from_slice(&f1);
+        buf.extend_from_slice(&f2);
+
+        let mut iter = WireMessageIteratorMut::new(&mut buf, 2);
+        {
+            let mut view = iter.next_view_mut().unwrap().unwrap();
+            view.set_offset(10);
+            view.set_timestamp(1000);
+        }
+        {
+            let mut view = iter.next_view_mut().unwrap().unwrap();
+            view.set_offset(20);
+            view.set_timestamp(2000);
+        }
+        assert!(iter.next_view_mut().is_none());
+
+        let views: Vec<_> = WireMessageIterator::new(&buf, 2)
+            .collect::<Result<Vec<_>, _>>()
+            .unwrap();
+        assert_eq!(views[0].offset(), 10);
+        assert_eq!(views[0].timestamp(), 1000);
+        assert_eq!(views[0].payload(), b"aaa");
+        assert_eq!(views[1].offset(), 20);
+        assert_eq!(views[1].timestamp(), 2000);
+        assert_eq!(views[1].payload(), b"bbb");
+    }
+
+    #[test]
+    fn iterator_mut_count_matches() {
+        let f1 = make_frame(b"a", b"", 1, 0);
+        let f2 = make_frame(b"b", b"", 2, 0);
+        let mut buf = Vec::new();
+        buf.extend_from_slice(&f1);
+        buf.extend_from_slice(&f2);
+
+        let mut iter = WireMessageIteratorMut::new(&mut buf, 2);
+        let mut count = 0;
+        while let Some(Ok(_)) = iter.next_view_mut() {
+            count += 1;
+        }
+        assert_eq!(count, 2);
+    }
+}
diff --git a/core/binary_protocol/src/primitives/consumer.rs b/core/binary_protocol/src/primitives/consumer.rs
new file mode 100644
index 000000000..255b6a38a
--- /dev/null
+++ b/core/binary_protocol/src/primitives/consumer.rs
@@ -0,0 +1,133 @@
+// 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.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8};
+use bytes::{BufMut, BytesMut};
+
+const KIND_CONSUMER: u8 = 1;
+const KIND_CONSUMER_GROUP: u8 = 2;
+
+/// Wire consumer type. Identifies either a single consumer or a consumer group.
+///
+/// Wire format: `[kind:1][identifier:variable]`
+/// - kind=1: consumer
+/// - kind=2: consumer group
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WireConsumer {
+    pub kind: u8,
+    pub id: WireIdentifier,
+}
+
+impl WireConsumer {
+    #[must_use]
+    pub const fn consumer(id: WireIdentifier) -> Self {
+        Self {
+            kind: KIND_CONSUMER,
+            id,
+        }
+    }
+
+    #[must_use]
+    pub const fn consumer_group(id: WireIdentifier) -> Self {
+        Self {
+            kind: KIND_CONSUMER_GROUP,
+            id,
+        }
+    }
+}
+
+impl WireEncode for WireConsumer {
+    fn encoded_size(&self) -> usize {
+        1 + self.id.encoded_size()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        buf.put_u8(self.kind);
+        self.id.encode(buf);
+    }
+}
+
+impl WireDecode for WireConsumer {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let kind = read_u8(buf, 0)?;
+        if kind != KIND_CONSUMER && kind != KIND_CONSUMER_GROUP {
+            return Err(WireError::UnknownDiscriminant {
+                type_name: "WireConsumer",
+                value: kind,
+                offset: 0,
+            });
+        }
+        let (id, id_consumed) = WireIdentifier::decode(&buf[1..])?;
+        Ok((Self { kind, id }, 1 + id_consumed))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip_consumer() {
+        let c = WireConsumer::consumer(WireIdentifier::numeric(42));
+        let bytes = c.to_bytes();
+        assert_eq!(bytes.len(), 1 + 6);
+        let (decoded, consumed) = WireConsumer::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, c);
+    }
+
+    #[test]
+    fn roundtrip_consumer_group() {
+        let c = WireConsumer::consumer_group(WireIdentifier::numeric(7));
+        let bytes = c.to_bytes();
+        let (decoded, consumed) = WireConsumer::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, c);
+        assert_eq!(decoded.kind, KIND_CONSUMER_GROUP);
+    }
+
+    #[test]
+    fn roundtrip_with_string_identifier() {
+        let c = WireConsumer::consumer(WireIdentifier::named("my-consumer").unwrap());
+        let bytes = c.to_bytes();
+        let (decoded, consumed) = WireConsumer::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, c);
+    }
+
+    #[test]
+    fn unknown_kind_rejected() {
+        let mut bytes = BytesMut::new();
+        bytes.put_u8(0xFF);
+        WireIdentifier::numeric(1).encode(&mut bytes);
+        assert!(WireConsumer::decode(&bytes).is_err());
+    }
+
+    #[test]
+    fn truncated_buffer() {
+        let c = WireConsumer::consumer(WireIdentifier::numeric(1));
+        let bytes = c.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                WireConsumer::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/identifier.rs b/core/binary_protocol/src/primitives/identifier.rs
similarity index 96%
rename from core/binary_protocol/src/identifier.rs
rename to core/binary_protocol/src/primitives/identifier.rs
index 79c5c03d7..af25c189c 100644
--- a/core/binary_protocol/src/identifier.rs
+++ b/core/binary_protocol/src/primitives/identifier.rs
@@ -20,9 +20,7 @@ use crate::codec::{WireDecode, WireEncode, read_bytes, read_str, read_u8};
 use bytes::{BufMut, BytesMut};
 use std::ops::Deref;
 
-// ---------------------------------------------------------------------------
 // WireName
-// ---------------------------------------------------------------------------
 
 /// Maximum byte length for a wire name (fits in a u8 length prefix).
 pub const MAX_WIRE_NAME_LENGTH: usize = 255;
@@ -113,9 +111,7 @@ impl WireDecode for WireName {
     }
 }
 
-// ---------------------------------------------------------------------------
 // WireIdentifier
-// ---------------------------------------------------------------------------
 
 const KIND_NUMERIC: u8 = 1;
 const KIND_STRING: u8 = 2;
@@ -243,9 +239,7 @@ impl std::fmt::Display for WireIdentifier {
     }
 }
 
-// ---------------------------------------------------------------------------
 // Tests
-// ---------------------------------------------------------------------------
 
 #[cfg(test)]
 mod tests {
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/primitives/mod.rs
similarity index 81%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/primitives/mod.rs
index e7b687f1f..dcbef623e 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/primitives/mod.rs
@@ -15,4 +15,10 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+//! Shared wire primitives reused across request and response types.
+
+pub mod consumer;
+pub mod identifier;
+pub mod partitioning;
+pub mod permissions;
+pub mod polling_strategy;
diff --git a/core/binary_protocol/src/primitives/partitioning.rs b/core/binary_protocol/src/primitives/partitioning.rs
new file mode 100644
index 000000000..4bfbf5f58
--- /dev/null
+++ b/core/binary_protocol/src/primitives/partitioning.rs
@@ -0,0 +1,271 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode, read_bytes, read_u8, read_u32_le};
+use bytes::{BufMut, BytesMut};
+
+const KIND_BALANCED: u8 = 1;
+const KIND_PARTITION_ID: u8 = 2;
+const KIND_MESSAGES_KEY: u8 = 3;
+
+/// Maximum key length for `MessagesKey` partitioning (u8 length prefix on the wire).
+pub const MAX_MESSAGES_KEY_LENGTH: usize = 255;
+
+/// Partitioning strategy for message routing.
+///
+/// Wire format: `[kind:1][length:1][value:0..255]`
+/// - `Balanced`:    kind=1, length=0, no value bytes
+/// - `PartitionId`: kind=2, length=4, value=u32 LE
+/// - `MessagesKey`: kind=3, length=1..255, value=raw bytes
+///
+/// Use [`WirePartitioning::messages_key`] to construct a `MessagesKey` variant
+/// with validation. Direct enum construction is possible but skips the length check.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum WirePartitioning {
+    Balanced,
+    PartitionId(u32),
+    MessagesKey(Vec<u8>),
+}
+
+impl WirePartitioning {
+    /// Create a `MessagesKey` partitioning with key length validation.
+    ///
+    /// # Errors
+    /// Returns `WireError::Validation` if `key` is empty or exceeds 255 bytes.
+    pub fn messages_key(key: Vec<u8>) -> Result<Self, WireError> {
+        if key.is_empty() {
+            return Err(WireError::Validation(
+                "messages_key partitioning cannot have empty key".to_string(),
+            ));
+        }
+        if key.len() > MAX_MESSAGES_KEY_LENGTH {
+            return Err(WireError::Validation(format!(
+                "messages_key length {} exceeds maximum {MAX_MESSAGES_KEY_LENGTH}",
+                key.len()
+            )));
+        }
+        Ok(Self::MessagesKey(key))
+    }
+}
+
+impl WireEncode for WirePartitioning {
+    fn encoded_size(&self) -> usize {
+        match self {
+            Self::Balanced => 2,
+            Self::PartitionId(_) => 2 + 4,
+            Self::MessagesKey(key) => 2 + key.len(),
+        }
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        match self {
+            Self::Balanced => {
+                buf.put_u8(KIND_BALANCED);
+                buf.put_u8(0);
+            }
+            Self::PartitionId(id) => {
+                buf.put_u8(KIND_PARTITION_ID);
+                buf.put_u8(4);
+                buf.put_u32_le(*id);
+            }
+            Self::MessagesKey(key) => {
+                debug_assert!(
+                    !key.is_empty() && key.len() <= MAX_MESSAGES_KEY_LENGTH,
+                    "MessagesKey length {} out of valid range 1..={MAX_MESSAGES_KEY_LENGTH}; \
+                     use WirePartitioning::messages_key() for validated construction",
+                    key.len()
+                );
+                buf.put_u8(KIND_MESSAGES_KEY);
+                #[allow(clippy::cast_possible_truncation)]
+                buf.put_u8(key.len() as u8);
+                buf.put_slice(key);
+            }
+        }
+    }
+}
+
+impl WireDecode for WirePartitioning {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let kind = read_u8(buf, 0)?;
+        let length = read_u8(buf, 1)? as usize;
+
+        match kind {
+            KIND_BALANCED => {
+                if length != 0 {
+                    return Err(WireError::Validation(format!(
+                        "balanced partitioning must have length 0, got {length}"
+                    )));
+                }
+                Ok((Self::Balanced, 2))
+            }
+            KIND_PARTITION_ID => {
+                if length != 4 {
+                    return Err(WireError::Validation(format!(
+                        "partition_id partitioning must have length 4, got {length}"
+                    )));
+                }
+                let id = read_u32_le(buf, 2)?;
+                Ok((Self::PartitionId(id), 6))
+            }
+            KIND_MESSAGES_KEY => {
+                if length == 0 {
+                    return Err(WireError::Validation(
+                        "messages_key partitioning cannot have empty key".to_string(),
+                    ));
+                }
+                let key = read_bytes(buf, 2, length)?;
+                Ok((Self::MessagesKey(key.to_vec()), 2 + length))
+            }
+            _ => Err(WireError::UnknownDiscriminant {
+                type_name: "WirePartitioning",
+                value: kind,
+                offset: 0,
+            }),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip_balanced() {
+        let p = WirePartitioning::Balanced;
+        let bytes = p.to_bytes();
+        assert_eq!(bytes.len(), 2);
+        let (decoded, consumed) = WirePartitioning::decode(&bytes).unwrap();
+        assert_eq!(consumed, 2);
+        assert_eq!(decoded, p);
+    }
+
+    #[test]
+    fn roundtrip_partition_id() {
+        let p = WirePartitioning::PartitionId(42);
+        let bytes = p.to_bytes();
+        assert_eq!(bytes.len(), 6);
+        let (decoded, consumed) = WirePartitioning::decode(&bytes).unwrap();
+        assert_eq!(consumed, 6);
+        assert_eq!(decoded, p);
+    }
+
+    #[test]
+    fn roundtrip_messages_key() {
+        let p = WirePartitioning::MessagesKey(b"user-123".to_vec());
+        let bytes = p.to_bytes();
+        assert_eq!(bytes.len(), 2 + 8);
+        let (decoded, consumed) = WirePartitioning::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, p);
+    }
+
+    #[test]
+    fn messages_key_max_length() {
+        let key = vec![0xAB; 255];
+        let p = WirePartitioning::MessagesKey(key.clone());
+        let bytes = p.to_bytes();
+        let (decoded, _) = WirePartitioning::decode(&bytes).unwrap();
+        assert_eq!(decoded, WirePartitioning::MessagesKey(key));
+    }
+
+    #[test]
+    fn messages_key_single_byte() {
+        let p = WirePartitioning::MessagesKey(vec![0x01]);
+        let bytes = p.to_bytes();
+        let (decoded, consumed) = WirePartitioning::decode(&bytes).unwrap();
+        assert_eq!(consumed, 3);
+        assert_eq!(decoded, p);
+    }
+
+    #[test]
+    fn empty_messages_key_rejected() {
+        let buf = [KIND_MESSAGES_KEY, 0x00];
+        assert!(WirePartitioning::decode(&buf).is_err());
+    }
+
+    #[test]
+    fn unknown_kind_rejected() {
+        let buf = [0xFF, 0x00];
+        assert!(WirePartitioning::decode(&buf).is_err());
+    }
+
+    #[test]
+    fn balanced_with_nonzero_length_rejected() {
+        let buf = [KIND_BALANCED, 0x01, 0x00];
+        assert!(WirePartitioning::decode(&buf).is_err());
+    }
+
+    #[test]
+    fn partition_id_with_wrong_length_rejected() {
+        let buf = [KIND_PARTITION_ID, 0x03, 0x00, 0x00, 0x00];
+        assert!(WirePartitioning::decode(&buf).is_err());
+    }
+
+    #[test]
+    fn truncated_buffer() {
+        let p = WirePartitioning::PartitionId(1);
+        let bytes = p.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                WirePartitioning::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn wire_compat_balanced_layout() {
+        let bytes = WirePartitioning::Balanced.to_bytes();
+        assert_eq!(&bytes[..], &[KIND_BALANCED, 0x00]);
+    }
+
+    #[test]
+    fn wire_compat_partition_id_layout() {
+        let bytes = WirePartitioning::PartitionId(1).to_bytes();
+        assert_eq!(
+            &bytes[..],
+            &[KIND_PARTITION_ID, 0x04, 0x01, 0x00, 0x00, 0x00]
+        );
+    }
+
+    #[test]
+    fn messages_key_constructor_validates_empty() {
+        assert!(WirePartitioning::messages_key(vec![]).is_err());
+    }
+
+    #[test]
+    fn messages_key_constructor_validates_too_long() {
+        let key = vec![0xAB; 256];
+        assert!(WirePartitioning::messages_key(key).is_err());
+    }
+
+    #[test]
+    fn messages_key_constructor_accepts_max() {
+        let key = vec![0xAB; 255];
+        let p = WirePartitioning::messages_key(key.clone()).unwrap();
+        assert_eq!(p, WirePartitioning::MessagesKey(key));
+    }
+
+    #[test]
+    fn messages_key_constructor_accepts_single_byte() {
+        let p = WirePartitioning::messages_key(vec![0x01]).unwrap();
+        let bytes = p.to_bytes();
+        let (decoded, _) = WirePartitioning::decode(&bytes).unwrap();
+        assert_eq!(decoded, p);
+    }
+}
diff --git a/core/binary_protocol/src/primitives/permissions.rs b/core/binary_protocol/src/primitives/permissions.rs
new file mode 100644
index 000000000..7a3b42e10
--- /dev/null
+++ b/core/binary_protocol/src/primitives/permissions.rs
@@ -0,0 +1,471 @@
+// 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.
+
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le};
+use bytes::{BufMut, BytesMut};
+
+const HAS_NEXT: u8 = 1;
+const NO_NEXT: u8 = 0;
+
+fn bool_to_u8(b: bool) -> u8 {
+    u8::from(b)
+}
+
+fn read_bool(buf: &[u8], offset: usize) -> Result<bool, WireError> {
+    Ok(read_u8(buf, offset)? != 0)
+}
+
+/// Full permission set for a user.
+///
+/// Wire format:
+/// ```text
+/// [10 x bool as u8: global permissions]
+/// [has_streams:1]
+///   loop streams:
+///     [stream_id:u32_le][6 x bool: stream perms][has_topics:1]
+///     loop topics:
+///       [topic_id:u32_le][4 x bool: topic perms][has_next_topic:1]
+///     [has_next_stream:1]
+/// ```
+///
+/// Streams and topics are stored as `Vec` sorted by ID for deterministic encoding.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WirePermissions {
+    pub global: WireGlobalPermissions,
+    pub streams: Vec<WireStreamPermissions>,
+}
+
+#[allow(clippy::struct_excessive_bools)]
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WireGlobalPermissions {
+    pub manage_servers: bool,
+    pub read_servers: bool,
+    pub manage_users: bool,
+    pub read_users: bool,
+    pub manage_streams: bool,
+    pub read_streams: bool,
+    pub manage_topics: bool,
+    pub read_topics: bool,
+    pub poll_messages: bool,
+    pub send_messages: bool,
+}
+
+#[allow(clippy::struct_excessive_bools)]
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WireStreamPermissions {
+    pub stream_id: u32,
+    pub manage_stream: bool,
+    pub read_stream: bool,
+    pub manage_topics: bool,
+    pub read_topics: bool,
+    pub poll_messages: bool,
+    pub send_messages: bool,
+    pub topics: Vec<WireTopicPermissions>,
+}
+
+#[allow(clippy::struct_excessive_bools)]
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WireTopicPermissions {
+    pub topic_id: u32,
+    pub manage_topic: bool,
+    pub read_topic: bool,
+    pub poll_messages: bool,
+    pub send_messages: bool,
+}
+
+impl WireGlobalPermissions {
+    fn encode_into(&self, buf: &mut BytesMut) {
+        buf.put_u8(bool_to_u8(self.manage_servers));
+        buf.put_u8(bool_to_u8(self.read_servers));
+        buf.put_u8(bool_to_u8(self.manage_users));
+        buf.put_u8(bool_to_u8(self.read_users));
+        buf.put_u8(bool_to_u8(self.manage_streams));
+        buf.put_u8(bool_to_u8(self.read_streams));
+        buf.put_u8(bool_to_u8(self.manage_topics));
+        buf.put_u8(bool_to_u8(self.read_topics));
+        buf.put_u8(bool_to_u8(self.poll_messages));
+        buf.put_u8(bool_to_u8(self.send_messages));
+    }
+
+    fn decode_at(buf: &[u8], pos: usize) -> Result<(Self, usize), WireError> {
+        let mut p = pos;
+        let manage_servers = read_bool(buf, p)?;
+        p += 1;
+        let read_servers = read_bool(buf, p)?;
+        p += 1;
+        let manage_users = read_bool(buf, p)?;
+        p += 1;
+        let read_users = read_bool(buf, p)?;
+        p += 1;
+        let manage_streams = read_bool(buf, p)?;
+        p += 1;
+        let read_streams = read_bool(buf, p)?;
+        p += 1;
+        let manage_topics = read_bool(buf, p)?;
+        p += 1;
+        let read_topics = read_bool(buf, p)?;
+        p += 1;
+        let poll_messages = read_bool(buf, p)?;
+        p += 1;
+        let send_messages = read_bool(buf, p)?;
+        p += 1;
+
+        Ok((
+            Self {
+                manage_servers,
+                read_servers,
+                manage_users,
+                read_users,
+                manage_streams,
+                read_streams,
+                manage_topics,
+                read_topics,
+                poll_messages,
+                send_messages,
+            },
+            p,
+        ))
+    }
+}
+
+impl WireTopicPermissions {
+    const ENCODED_SIZE: usize = 4 + 4 + 1; // topic_id + 4 bools + has_next_topic
+
+    fn encode_into(&self, buf: &mut BytesMut) {
+        buf.put_u32_le(self.topic_id);
+        buf.put_u8(bool_to_u8(self.manage_topic));
+        buf.put_u8(bool_to_u8(self.read_topic));
+        buf.put_u8(bool_to_u8(self.poll_messages));
+        buf.put_u8(bool_to_u8(self.send_messages));
+    }
+
+    fn decode_at(buf: &[u8], pos: usize) -> Result<(Self, usize), WireError> {
+        let mut p = pos;
+        let topic_id = read_u32_le(buf, p)?;
+        p += 4;
+        let manage_topic = read_bool(buf, p)?;
+        p += 1;
+        let read_topic = read_bool(buf, p)?;
+        p += 1;
+        let poll_messages = read_bool(buf, p)?;
+        p += 1;
+        let send_messages = read_bool(buf, p)?;
+        p += 1;
+
+        Ok((
+            Self {
+                topic_id,
+                manage_topic,
+                read_topic,
+                poll_messages,
+                send_messages,
+            },
+            p,
+        ))
+    }
+}
+
+impl WireStreamPermissions {
+    #[allow(clippy::missing_const_for_fn)]
+    fn encoded_size(&self) -> usize {
+        // stream_id(4) + 6 bools(6) + has_topics(1) + topics + has_next_stream(1)
+        let topics_size: usize = if self.topics.is_empty() {
+            0
+        } else {
+            self.topics.len() * WireTopicPermissions::ENCODED_SIZE
+        };
+        4 + 6 + 1 + topics_size + 1
+    }
+
+    fn encode_into(&self, buf: &mut BytesMut) {
+        buf.put_u32_le(self.stream_id);
+        buf.put_u8(bool_to_u8(self.manage_stream));
+        buf.put_u8(bool_to_u8(self.read_stream));
+        buf.put_u8(bool_to_u8(self.manage_topics));
+        buf.put_u8(bool_to_u8(self.read_topics));
+        buf.put_u8(bool_to_u8(self.poll_messages));
+        buf.put_u8(bool_to_u8(self.send_messages));
+
+        if self.topics.is_empty() {
+            buf.put_u8(NO_NEXT);
+        } else {
+            buf.put_u8(HAS_NEXT);
+            for (i, topic) in self.topics.iter().enumerate() {
+                topic.encode_into(buf);
+                let is_last = i == self.topics.len() - 1;
+                buf.put_u8(if is_last { NO_NEXT } else { HAS_NEXT });
+            }
+        }
+    }
+
+    fn decode_at(buf: &[u8], pos: usize) -> Result<(Self, usize), WireError> {
+        let mut p = pos;
+        let stream_id = read_u32_le(buf, p)?;
+        p += 4;
+        let manage_stream = read_bool(buf, p)?;
+        p += 1;
+        let read_stream = read_bool(buf, p)?;
+        p += 1;
+        let manage_topics = read_bool(buf, p)?;
+        p += 1;
+        let read_topics = read_bool(buf, p)?;
+        p += 1;
+        let poll_messages = read_bool(buf, p)?;
+        p += 1;
+        let send_messages = read_bool(buf, p)?;
+        p += 1;
+
+        let has_topics = read_bool(buf, p)?;
+        p += 1;
+
+        let mut topics = Vec::new();
+        if has_topics {
+            loop {
+                let (topic, next_p) = WireTopicPermissions::decode_at(buf, p)?;
+                p = next_p;
+                topics.push(topic);
+                let has_next = read_bool(buf, p)?;
+                p += 1;
+                if !has_next {
+                    break;
+                }
+            }
+        }
+
+        Ok((
+            Self {
+                stream_id,
+                manage_stream,
+                read_stream,
+                manage_topics,
+                read_topics,
+                poll_messages,
+                send_messages,
+                topics,
+            },
+            p,
+        ))
+    }
+}
+
+impl WireEncode for WirePermissions {
+    fn encoded_size(&self) -> usize {
+        // global(10) + has_streams(1) + streams
+        let streams_size: usize = if self.streams.is_empty() {
+            0
+        } else {
+            self.streams
+                .iter()
+                .map(WireStreamPermissions::encoded_size)
+                .sum()
+        };
+        10 + 1 + streams_size
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.global.encode_into(buf);
+
+        if self.streams.is_empty() {
+            buf.put_u8(NO_NEXT);
+        } else {
+            buf.put_u8(HAS_NEXT);
+            for (i, stream) in self.streams.iter().enumerate() {
+                stream.encode_into(buf);
+                let is_last = i == self.streams.len() - 1;
+                buf.put_u8(if is_last { NO_NEXT } else { HAS_NEXT });
+            }
+        }
+    }
+}
+
+impl WireDecode for WirePermissions {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (global, mut p) = WireGlobalPermissions::decode_at(buf, 0)?;
+
+        let has_streams = read_bool(buf, p)?;
+        p += 1;
+
+        let mut streams = Vec::new();
+        if has_streams {
+            loop {
+                let (stream, next_p) = WireStreamPermissions::decode_at(buf, p)?;
+                p = next_p;
+                streams.push(stream);
+                let has_next = read_bool(buf, p)?;
+                p += 1;
+                if !has_next {
+                    break;
+                }
+            }
+        }
+
+        Ok((Self { global, streams }, p))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn make_global(all: bool) -> WireGlobalPermissions {
+        WireGlobalPermissions {
+            manage_servers: all,
+            read_servers: all,
+            manage_users: all,
+            read_users: all,
+            manage_streams: all,
+            read_streams: all,
+            manage_topics: all,
+            read_topics: all,
+            poll_messages: all,
+            send_messages: all,
+        }
+    }
+
+    fn make_topic(id: u32) -> WireTopicPermissions {
+        WireTopicPermissions {
+            topic_id: id,
+            manage_topic: true,
+            read_topic: true,
+            poll_messages: false,
+            send_messages: true,
+        }
+    }
+
+    fn make_stream(id: u32, topics: Vec<WireTopicPermissions>) -> WireStreamPermissions {
+        WireStreamPermissions {
+            stream_id: id,
+            manage_stream: true,
+            read_stream: true,
+            manage_topics: false,
+            read_topics: true,
+            poll_messages: true,
+            send_messages: false,
+            topics,
+        }
+    }
+
+    #[test]
+    fn roundtrip_global_only() {
+        let perms = WirePermissions {
+            global: make_global(true),
+            streams: vec![],
+        };
+        let bytes = perms.to_bytes();
+        assert_eq!(bytes.len(), 11); // 10 global + 1 has_streams=0
+        let (decoded, consumed) = WirePermissions::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, perms);
+    }
+
+    #[test]
+    fn roundtrip_with_streams_no_topics() {
+        let perms = WirePermissions {
+            global: make_global(false),
+            streams: vec![make_stream(1, vec![]), make_stream(2, vec![])],
+        };
+        let bytes = perms.to_bytes();
+        let (decoded, consumed) = WirePermissions::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, perms);
+    }
+
+    #[test]
+    fn roundtrip_with_streams_and_topics() {
+        let perms = WirePermissions {
+            global: make_global(true),
+            streams: vec![
+                make_stream(1, vec![make_topic(10), make_topic(20)]),
+                make_stream(2, vec![make_topic(30)]),
+            ],
+        };
+        let bytes = perms.to_bytes();
+        let (decoded, consumed) = WirePermissions::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, perms);
+    }
+
+    #[test]
+    fn roundtrip_single_stream_single_topic() {
+        let perms = WirePermissions {
+            global: make_global(false),
+            streams: vec![make_stream(42, vec![make_topic(7)])],
+        };
+        let bytes = perms.to_bytes();
+        let (decoded, consumed) = WirePermissions::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, perms);
+    }
+
+    #[test]
+    fn global_permissions_all_false() {
+        let perms = WirePermissions {
+            global: make_global(false),
+            streams: vec![],
+        };
+        let bytes = perms.to_bytes();
+        // First 10 bytes should all be 0
+        for byte in &bytes[..10] {
+            assert_eq!(*byte, 0);
+        }
+        let (decoded, _) = WirePermissions::decode(&bytes).unwrap();
+        assert_eq!(decoded, perms);
+    }
+
+    #[test]
+    fn global_permissions_all_true() {
+        let perms = WirePermissions {
+            global: make_global(true),
+            streams: vec![],
+        };
+        let bytes = perms.to_bytes();
+        for byte in &bytes[..10] {
+            assert_eq!(*byte, 1);
+        }
+    }
+
+    #[test]
+    fn truncated_buffer_global_only() {
+        let perms = WirePermissions {
+            global: make_global(true),
+            streams: vec![],
+        };
+        let bytes = perms.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                WirePermissions::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn truncated_buffer_with_streams() {
+        let perms = WirePermissions {
+            global: make_global(true),
+            streams: vec![make_stream(1, vec![make_topic(10)])],
+        };
+        let bytes = perms.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                WirePermissions::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/primitives/polling_strategy.rs b/core/binary_protocol/src/primitives/polling_strategy.rs
new file mode 100644
index 000000000..eee3a48a7
--- /dev/null
+++ b/core/binary_protocol/src/primitives/polling_strategy.rs
@@ -0,0 +1,196 @@
+// 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.
+
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u64_le};
+use bytes::{BufMut, BytesMut};
+
+const KIND_OFFSET: u8 = 1;
+const KIND_TIMESTAMP: u8 = 2;
+const KIND_FIRST: u8 = 3;
+const KIND_LAST: u8 = 4;
+const KIND_NEXT: u8 = 5;
+
+/// Polling strategy for consuming messages.
+///
+/// Wire format: `[kind:1][value:8]` = 9 bytes fixed.
+/// - Offset(1):    resume from a specific offset
+/// - Timestamp(2): resume from a specific timestamp (microseconds)
+/// - First(3):     start from the beginning
+/// - Last(4):      start from the end
+/// - Next(5):      continue from last stored offset
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WirePollingStrategy {
+    pub kind: u8,
+    pub value: u64,
+}
+
+impl WirePollingStrategy {
+    #[must_use]
+    pub const fn offset(value: u64) -> Self {
+        Self {
+            kind: KIND_OFFSET,
+            value,
+        }
+    }
+
+    #[must_use]
+    pub const fn timestamp(value: u64) -> Self {
+        Self {
+            kind: KIND_TIMESTAMP,
+            value,
+        }
+    }
+
+    #[must_use]
+    pub const fn first() -> Self {
+        Self {
+            kind: KIND_FIRST,
+            value: 0,
+        }
+    }
+
+    #[must_use]
+    pub const fn last() -> Self {
+        Self {
+            kind: KIND_LAST,
+            value: 0,
+        }
+    }
+
+    #[must_use]
+    pub const fn next() -> Self {
+        Self {
+            kind: KIND_NEXT,
+            value: 0,
+        }
+    }
+}
+
+impl WireEncode for WirePollingStrategy {
+    fn encoded_size(&self) -> usize {
+        9
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        buf.put_u8(self.kind);
+        buf.put_u64_le(self.value);
+    }
+}
+
+impl WireDecode for WirePollingStrategy {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let kind = read_u8(buf, 0)?;
+        match kind {
+            KIND_OFFSET | KIND_TIMESTAMP | KIND_FIRST | KIND_LAST | KIND_NEXT => {}
+            _ => {
+                return Err(WireError::UnknownDiscriminant {
+                    type_name: "WirePollingStrategy",
+                    value: kind,
+                    offset: 0,
+                });
+            }
+        }
+        let value = read_u64_le(buf, 1)?;
+        Ok((Self { kind, value }, 9))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip_offset() {
+        let s = WirePollingStrategy::offset(100);
+        let bytes = s.to_bytes();
+        assert_eq!(bytes.len(), 9);
+        let (decoded, consumed) = WirePollingStrategy::decode(&bytes).unwrap();
+        assert_eq!(consumed, 9);
+        assert_eq!(decoded, s);
+    }
+
+    #[test]
+    fn roundtrip_timestamp() {
+        let s = WirePollingStrategy::timestamp(1_700_000_000_000);
+        let bytes = s.to_bytes();
+        let (decoded, consumed) = WirePollingStrategy::decode(&bytes).unwrap();
+        assert_eq!(consumed, 9);
+        assert_eq!(decoded, s);
+    }
+
+    #[test]
+    fn roundtrip_first() {
+        let s = WirePollingStrategy::first();
+        let bytes = s.to_bytes();
+        let (decoded, _) = WirePollingStrategy::decode(&bytes).unwrap();
+        assert_eq!(decoded, s);
+        assert_eq!(decoded.value, 0);
+    }
+
+    #[test]
+    fn roundtrip_last() {
+        let s = WirePollingStrategy::last();
+        let bytes = s.to_bytes();
+        let (decoded, _) = WirePollingStrategy::decode(&bytes).unwrap();
+        assert_eq!(decoded, s);
+    }
+
+    #[test]
+    fn roundtrip_next() {
+        let s = WirePollingStrategy::next();
+        let bytes = s.to_bytes();
+        let (decoded, _) = WirePollingStrategy::decode(&bytes).unwrap();
+        assert_eq!(decoded, s);
+    }
+
+    #[test]
+    fn unknown_kind_rejected() {
+        let mut buf = BytesMut::new();
+        buf.put_u8(0xFF);
+        buf.put_u64_le(0);
+        assert!(WirePollingStrategy::decode(&buf).is_err());
+    }
+
+    #[test]
+    fn kind_zero_rejected() {
+        let mut buf = BytesMut::new();
+        buf.put_u8(0);
+        buf.put_u64_le(0);
+        assert!(WirePollingStrategy::decode(&buf).is_err());
+    }
+
+    #[test]
+    fn truncated_buffer() {
+        let s = WirePollingStrategy::offset(1);
+        let bytes = s.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                WirePollingStrategy::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn wire_compat_layout() {
+        let s = WirePollingStrategy::offset(1);
+        let bytes = s.to_bytes();
+        assert_eq!(bytes[0], KIND_OFFSET);
+        assert_eq!(u64::from_le_bytes(bytes[1..9].try_into().unwrap()), 1);
+    }
+}
diff --git a/core/binary_protocol/src/requests/consumer_groups/create_consumer_group.rs b/core/binary_protocol/src/requests/consumer_groups/create_consumer_group.rs
new file mode 100644
index 000000000..b5bb9575f
--- /dev/null
+++ b/core/binary_protocol/src/requests/consumer_groups/create_consumer_group.rs
@@ -0,0 +1,126 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode};
+use crate::primitives::identifier::WireName;
+use bytes::BytesMut;
+
+/// `CreateConsumerGroup` request.
+///
+/// Wire format: `[stream_id][topic_id][name_len:1][name:N]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CreateConsumerGroupRequest {
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub name: WireName,
+}
+
+impl WireEncode for CreateConsumerGroupRequest {
+    fn encoded_size(&self) -> usize {
+        self.stream_id.encoded_size() + self.topic_id.encoded_size() + self.name.encoded_size()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        self.name.encode(buf);
+    }
+}
+
+impl WireDecode for CreateConsumerGroupRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let mut pos = 0;
+        let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (name, n) = WireName::decode(&buf[pos..])?;
+        pos += n;
+        Ok((
+            Self {
+                stream_id,
+                topic_id,
+                name,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = CreateConsumerGroupRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            name: WireName::new("my-group").unwrap(),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = CreateConsumerGroupRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named_identifiers() {
+        let req = CreateConsumerGroupRequest {
+            stream_id: WireIdentifier::named("stream-1").unwrap(),
+            topic_id: WireIdentifier::named("topic-1").unwrap(),
+            name: WireName::new("consumer-group-1").unwrap(),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = CreateConsumerGroupRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = CreateConsumerGroupRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            name: WireName::new("grp").unwrap(),
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                CreateConsumerGroupRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn wire_compat_byte_layout() {
+        let req = CreateConsumerGroupRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            name: WireName::new("grp").unwrap(),
+        };
+        let bytes = req.to_bytes();
+        // stream_id: [1,4, 1,0,0,0] + topic_id: [1,4, 2,0,0,0] + name: [3, g,r,p]
+        assert_eq!(
+            &bytes[..],
+            &[1, 4, 1, 0, 0, 0, 1, 4, 2, 0, 0, 0, 3, b'g', b'r', b'p']
+        );
+    }
+}
diff --git a/core/binary_protocol/src/requests/consumer_groups/delete_consumer_group.rs b/core/binary_protocol/src/requests/consumer_groups/delete_consumer_group.rs
new file mode 100644
index 000000000..d26e0d3da
--- /dev/null
+++ b/core/binary_protocol/src/requests/consumer_groups/delete_consumer_group.rs
@@ -0,0 +1,110 @@
+// 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.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode};
+use bytes::BytesMut;
+
+/// `DeleteConsumerGroup` request.
+///
+/// Wire format: `[stream_id][topic_id][group_id]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DeleteConsumerGroupRequest {
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub group_id: WireIdentifier,
+}
+
+impl WireEncode for DeleteConsumerGroupRequest {
+    fn encoded_size(&self) -> usize {
+        self.stream_id.encoded_size() + self.topic_id.encoded_size() + self.group_id.encoded_size()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        self.group_id.encode(buf);
+    }
+}
+
+impl WireDecode for DeleteConsumerGroupRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let mut pos = 0;
+        let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (group_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        Ok((
+            Self {
+                stream_id,
+                topic_id,
+                group_id,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = DeleteConsumerGroupRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            group_id: WireIdentifier::numeric(3),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = DeleteConsumerGroupRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named() {
+        let req = DeleteConsumerGroupRequest {
+            stream_id: WireIdentifier::named("s").unwrap(),
+            topic_id: WireIdentifier::named("t").unwrap(),
+            group_id: WireIdentifier::named("g").unwrap(),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = DeleteConsumerGroupRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = DeleteConsumerGroupRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            group_id: WireIdentifier::numeric(3),
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                DeleteConsumerGroupRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/requests/consumer_groups/get_consumer_group.rs b/core/binary_protocol/src/requests/consumer_groups/get_consumer_group.rs
new file mode 100644
index 000000000..1589b1cc7
--- /dev/null
+++ b/core/binary_protocol/src/requests/consumer_groups/get_consumer_group.rs
@@ -0,0 +1,110 @@
+// 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.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode};
+use bytes::BytesMut;
+
+/// `GetConsumerGroup` request.
+///
+/// Wire format: `[stream_id][topic_id][group_id]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GetConsumerGroupRequest {
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub group_id: WireIdentifier,
+}
+
+impl WireEncode for GetConsumerGroupRequest {
+    fn encoded_size(&self) -> usize {
+        self.stream_id.encoded_size() + self.topic_id.encoded_size() + self.group_id.encoded_size()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        self.group_id.encode(buf);
+    }
+}
+
+impl WireDecode for GetConsumerGroupRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let mut pos = 0;
+        let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (group_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        Ok((
+            Self {
+                stream_id,
+                topic_id,
+                group_id,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip_numeric() {
+        let req = GetConsumerGroupRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            group_id: WireIdentifier::numeric(3),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = GetConsumerGroupRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named() {
+        let req = GetConsumerGroupRequest {
+            stream_id: WireIdentifier::named("stream-1").unwrap(),
+            topic_id: WireIdentifier::named("topic-1").unwrap(),
+            group_id: WireIdentifier::named("group-1").unwrap(),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = GetConsumerGroupRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = GetConsumerGroupRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            group_id: WireIdentifier::numeric(3),
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                GetConsumerGroupRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/consumer_groups/get_consumer_groups.rs
similarity index 52%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/requests/consumer_groups/get_consumer_groups.rs
index a8085f099..2dd65a312 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/consumer_groups/get_consumer_groups.rs
@@ -18,33 +18,42 @@
 use crate::WireError;
 use crate::WireIdentifier;
 use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
 use bytes::BytesMut;
 
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// `GetConsumerGroups` request.
+///
+/// Wire format: `[stream_id][topic_id]`
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
+pub struct GetConsumerGroupsRequest {
     pub stream_id: WireIdentifier,
-    pub name: WireName,
+    pub topic_id: WireIdentifier,
 }
 
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for GetConsumerGroupsRequest {
     fn encoded_size(&self) -> usize {
-        self.stream_id.encoded_size() + self.name.encoded_size()
+        self.stream_id.encoded_size() + self.topic_id.encoded_size()
     }
 
     fn encode(&self, buf: &mut BytesMut) {
         self.stream_id.encode(buf);
-        self.name.encode(buf);
+        self.topic_id.encode(buf);
     }
 }
 
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for GetConsumerGroupsRequest {
     fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
-        let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
-        let (name, consumed) = WireName::decode(&buf[pos..])?;
-        pos += consumed;
-        Ok((Self { stream_id, name }, pos))
+        let mut pos = 0;
+        let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        Ok((
+            Self {
+                stream_id,
+                topic_id,
+            },
+            pos,
+        ))
     }
 }
 
@@ -53,27 +62,39 @@ mod tests {
     use super::*;
 
     #[test]
-    fn roundtrip() {
-        let req = UpdateStreamRequest {
-            stream_id: WireIdentifier::named("old-name").unwrap(),
-            name: WireName::new("new-name").unwrap(),
+    fn roundtrip_numeric() {
+        let req = GetConsumerGroupsRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = GetConsumerGroupsRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named() {
+        let req = GetConsumerGroupsRequest {
+            stream_id: WireIdentifier::named("stream-1").unwrap(),
+            topic_id: WireIdentifier::named("topic-1").unwrap(),
         };
         let bytes = req.to_bytes();
-        let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+        let (decoded, consumed) = GetConsumerGroupsRequest::decode(&bytes).unwrap();
         assert_eq!(consumed, bytes.len());
         assert_eq!(decoded, req);
     }
 
     #[test]
     fn truncated_returns_error() {
-        let req = UpdateStreamRequest {
+        let req = GetConsumerGroupsRequest {
             stream_id: WireIdentifier::numeric(1),
-            name: WireName::new("test").unwrap(),
+            topic_id: WireIdentifier::numeric(2),
         };
         let bytes = req.to_bytes();
         for i in 0..bytes.len() {
             assert!(
-                UpdateStreamRequest::decode(&bytes[..i]).is_err(),
+                GetConsumerGroupsRequest::decode(&bytes[..i]).is_err(),
                 "expected error for truncation at byte {i}"
             );
         }
diff --git a/core/binary_protocol/src/requests/consumer_groups/join_consumer_group.rs b/core/binary_protocol/src/requests/consumer_groups/join_consumer_group.rs
new file mode 100644
index 000000000..75eb12811
--- /dev/null
+++ b/core/binary_protocol/src/requests/consumer_groups/join_consumer_group.rs
@@ -0,0 +1,110 @@
+// 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.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode};
+use bytes::BytesMut;
+
+/// `JoinConsumerGroup` request.
+///
+/// Wire format: `[stream_id][topic_id][group_id]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct JoinConsumerGroupRequest {
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub group_id: WireIdentifier,
+}
+
+impl WireEncode for JoinConsumerGroupRequest {
+    fn encoded_size(&self) -> usize {
+        self.stream_id.encoded_size() + self.topic_id.encoded_size() + self.group_id.encoded_size()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        self.group_id.encode(buf);
+    }
+}
+
+impl WireDecode for JoinConsumerGroupRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let mut pos = 0;
+        let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (group_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        Ok((
+            Self {
+                stream_id,
+                topic_id,
+                group_id,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = JoinConsumerGroupRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            group_id: WireIdentifier::numeric(3),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = JoinConsumerGroupRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named() {
+        let req = JoinConsumerGroupRequest {
+            stream_id: WireIdentifier::named("stream-1").unwrap(),
+            topic_id: WireIdentifier::named("topic-1").unwrap(),
+            group_id: WireIdentifier::named("group-1").unwrap(),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = JoinConsumerGroupRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = JoinConsumerGroupRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            group_id: WireIdentifier::numeric(3),
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                JoinConsumerGroupRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/requests/consumer_groups/leave_consumer_group.rs b/core/binary_protocol/src/requests/consumer_groups/leave_consumer_group.rs
new file mode 100644
index 000000000..621922c3e
--- /dev/null
+++ b/core/binary_protocol/src/requests/consumer_groups/leave_consumer_group.rs
@@ -0,0 +1,110 @@
+// 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.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode};
+use bytes::BytesMut;
+
+/// `LeaveConsumerGroup` request.
+///
+/// Wire format: `[stream_id][topic_id][group_id]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct LeaveConsumerGroupRequest {
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub group_id: WireIdentifier,
+}
+
+impl WireEncode for LeaveConsumerGroupRequest {
+    fn encoded_size(&self) -> usize {
+        self.stream_id.encoded_size() + self.topic_id.encoded_size() + self.group_id.encoded_size()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        self.group_id.encode(buf);
+    }
+}
+
+impl WireDecode for LeaveConsumerGroupRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let mut pos = 0;
+        let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (group_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        Ok((
+            Self {
+                stream_id,
+                topic_id,
+                group_id,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = LeaveConsumerGroupRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            group_id: WireIdentifier::numeric(3),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = LeaveConsumerGroupRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named() {
+        let req = LeaveConsumerGroupRequest {
+            stream_id: WireIdentifier::named("stream-1").unwrap(),
+            topic_id: WireIdentifier::named("topic-1").unwrap(),
+            group_id: WireIdentifier::named("group-1").unwrap(),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = LeaveConsumerGroupRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = LeaveConsumerGroupRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            group_id: WireIdentifier::numeric(3),
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                LeaveConsumerGroupRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/consumer_groups/mod.rs
similarity index 60%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/consumer_groups/mod.rs
index 7b062a665..e82c728e8 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/consumer_groups/mod.rs
@@ -15,26 +15,16 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+pub mod create_consumer_group;
+pub mod delete_consumer_group;
+pub mod get_consumer_group;
+pub mod get_consumer_groups;
+pub mod join_consumer_group;
+pub mod leave_consumer_group;
 
-use crate::WireError;
-use crate::codec::{WireDecode, WireEncode};
-use bytes::BytesMut;
-
-/// Marker type for commands that return an empty response payload.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
-
-impl WireEncode for EmptyResponse {
-    fn encoded_size(&self) -> usize {
-        0
-    }
-
-    fn encode(&self, _buf: &mut BytesMut) {}
-}
-
-impl WireDecode for EmptyResponse {
-    fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
-        Ok((Self, 0))
-    }
-}
+pub use create_consumer_group::CreateConsumerGroupRequest;
+pub use delete_consumer_group::DeleteConsumerGroupRequest;
+pub use get_consumer_group::GetConsumerGroupRequest;
+pub use get_consumer_groups::GetConsumerGroupsRequest;
+pub use join_consumer_group::JoinConsumerGroupRequest;
+pub use leave_consumer_group::LeaveConsumerGroupRequest;
diff --git a/core/binary_protocol/src/requests/consumer_offsets/delete_consumer_offset.rs b/core/binary_protocol/src/requests/consumer_offsets/delete_consumer_offset.rs
new file mode 100644
index 000000000..1c3c480fe
--- /dev/null
+++ b/core/binary_protocol/src/requests/consumer_offsets/delete_consumer_offset.rs
@@ -0,0 +1,175 @@
+// 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.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le};
+use crate::primitives::consumer::WireConsumer;
+use bytes::{BufMut, BytesMut};
+
+/// `DeleteConsumerOffset` request.
+///
+/// Wire format:
+/// ```text
+/// [consumer][stream_id][topic_id][partition_flag:1][partition_id:4 LE]
+/// ```
+///
+/// `partition_id` encoding: a u8 flag (1=Some, 0=None) followed by 4 bytes
+/// for the u32 value (0 when None).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DeleteConsumerOffsetRequest {
+    pub consumer: WireConsumer,
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub partition_id: Option<u32>,
+}
+
+impl WireEncode for DeleteConsumerOffsetRequest {
+    fn encoded_size(&self) -> usize {
+        self.consumer.encoded_size()
+            + self.stream_id.encoded_size()
+            + self.topic_id.encoded_size()
+            + 1
+            + 4
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.consumer.encode(buf);
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        if let Some(pid) = self.partition_id {
+            buf.put_u8(1);
+            buf.put_u32_le(pid);
+        } else {
+            buf.put_u8(0);
+            buf.put_u32_le(0);
+        }
+    }
+}
+
+impl WireDecode for DeleteConsumerOffsetRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let mut pos = 0;
+        let (consumer, n) = WireConsumer::decode(&buf[pos..])?;
+        pos += n;
+        let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let partition_flag = read_u8(buf, pos)?;
+        pos += 1;
+        let partition_raw = read_u32_le(buf, pos)?;
+        pos += 4;
+        let partition_id = if partition_flag == 1 {
+            Some(partition_raw)
+        } else {
+            None
+        };
+        Ok((
+            Self {
+                consumer,
+                stream_id,
+                topic_id,
+                partition_id,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip_with_partition() {
+        let req = DeleteConsumerOffsetRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(10),
+            topic_id: WireIdentifier::numeric(20),
+            partition_id: Some(5),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = DeleteConsumerOffsetRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_without_partition() {
+        let req = DeleteConsumerOffsetRequest {
+            consumer: WireConsumer::consumer_group(WireIdentifier::numeric(3)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(1),
+            partition_id: None,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = DeleteConsumerOffsetRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named_identifiers() {
+        let req = DeleteConsumerOffsetRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::named("my-consumer").unwrap()),
+            stream_id: WireIdentifier::named("stream-1").unwrap(),
+            topic_id: WireIdentifier::named("topic-1").unwrap(),
+            partition_id: Some(0),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = DeleteConsumerOffsetRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn partition_none_encodes_zero_bytes() {
+        let req = DeleteConsumerOffsetRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(1),
+            partition_id: None,
+        };
+        let bytes = req.to_bytes();
+        let partition_offset = req.consumer.encoded_size()
+            + req.stream_id.encoded_size()
+            + req.topic_id.encoded_size();
+        assert_eq!(bytes[partition_offset], 0);
+        assert_eq!(
+            &bytes[partition_offset + 1..partition_offset + 5],
+            &[0, 0, 0, 0]
+        );
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = DeleteConsumerOffsetRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(1),
+            partition_id: Some(1),
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                DeleteConsumerOffsetRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/requests/consumer_offsets/get_consumer_offset.rs b/core/binary_protocol/src/requests/consumer_offsets/get_consumer_offset.rs
new file mode 100644
index 000000000..e256916af
--- /dev/null
+++ b/core/binary_protocol/src/requests/consumer_offsets/get_consumer_offset.rs
@@ -0,0 +1,175 @@
+// 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.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le};
+use crate::primitives::consumer::WireConsumer;
+use bytes::{BufMut, BytesMut};
+
+/// `GetConsumerOffset` request.
+///
+/// Wire format:
+/// ```text
+/// [consumer][stream_id][topic_id][partition_flag:1][partition_id:4 LE]
+/// ```
+///
+/// `partition_id` encoding: a u8 flag (1=Some, 0=None) followed by 4 bytes
+/// for the u32 value (0 when None).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GetConsumerOffsetRequest {
+    pub consumer: WireConsumer,
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub partition_id: Option<u32>,
+}
+
+impl WireEncode for GetConsumerOffsetRequest {
+    fn encoded_size(&self) -> usize {
+        self.consumer.encoded_size()
+            + self.stream_id.encoded_size()
+            + self.topic_id.encoded_size()
+            + 1
+            + 4
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.consumer.encode(buf);
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        if let Some(pid) = self.partition_id {
+            buf.put_u8(1);
+            buf.put_u32_le(pid);
+        } else {
+            buf.put_u8(0);
+            buf.put_u32_le(0);
+        }
+    }
+}
+
+impl WireDecode for GetConsumerOffsetRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let mut pos = 0;
+        let (consumer, n) = WireConsumer::decode(&buf[pos..])?;
+        pos += n;
+        let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let partition_flag = read_u8(buf, pos)?;
+        pos += 1;
+        let partition_raw = read_u32_le(buf, pos)?;
+        pos += 4;
+        let partition_id = if partition_flag == 1 {
+            Some(partition_raw)
+        } else {
+            None
+        };
+        Ok((
+            Self {
+                consumer,
+                stream_id,
+                topic_id,
+                partition_id,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip_with_partition() {
+        let req = GetConsumerOffsetRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(10),
+            topic_id: WireIdentifier::numeric(20),
+            partition_id: Some(5),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = GetConsumerOffsetRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_without_partition() {
+        let req = GetConsumerOffsetRequest {
+            consumer: WireConsumer::consumer_group(WireIdentifier::numeric(3)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(1),
+            partition_id: None,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = GetConsumerOffsetRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named_identifiers() {
+        let req = GetConsumerOffsetRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::named("my-consumer").unwrap()),
+            stream_id: WireIdentifier::named("stream-1").unwrap(),
+            topic_id: WireIdentifier::named("topic-1").unwrap(),
+            partition_id: Some(0),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = GetConsumerOffsetRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn partition_none_encodes_zero_bytes() {
+        let req = GetConsumerOffsetRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(1),
+            partition_id: None,
+        };
+        let bytes = req.to_bytes();
+        let partition_offset = req.consumer.encoded_size()
+            + req.stream_id.encoded_size()
+            + req.topic_id.encoded_size();
+        assert_eq!(bytes[partition_offset], 0);
+        assert_eq!(
+            &bytes[partition_offset + 1..partition_offset + 5],
+            &[0, 0, 0, 0]
+        );
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = GetConsumerOffsetRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(1),
+            partition_id: Some(1),
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                GetConsumerOffsetRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/requests/consumer_offsets/mod.rs
similarity index 74%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/requests/consumer_offsets/mod.rs
index e7b687f1f..bde4d02b5 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/requests/consumer_offsets/mod.rs
@@ -15,4 +15,10 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+pub mod delete_consumer_offset;
+pub mod get_consumer_offset;
+pub mod store_consumer_offset;
+
+pub use delete_consumer_offset::DeleteConsumerOffsetRequest;
+pub use get_consumer_offset::GetConsumerOffsetRequest;
+pub use store_consumer_offset::StoreConsumerOffsetRequest;
diff --git a/core/binary_protocol/src/requests/consumer_offsets/store_consumer_offset.rs b/core/binary_protocol/src/requests/consumer_offsets/store_consumer_offset.rs
new file mode 100644
index 000000000..10bfd94ff
--- /dev/null
+++ b/core/binary_protocol/src/requests/consumer_offsets/store_consumer_offset.rs
@@ -0,0 +1,180 @@
+// 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.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le, read_u64_le};
+use crate::primitives::consumer::WireConsumer;
+use bytes::{BufMut, BytesMut};
+
+/// `StoreConsumerOffset` request.
+///
+/// Wire format:
+/// ```text
+/// [consumer][stream_id][topic_id][partition_flag:1][partition_id:4 LE][offset:8 LE]
+/// ```
+///
+/// `partition_id` encoding: a u8 flag (1=Some, 0=None) followed by 4 bytes
+/// for the u32 value (0 when None).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct StoreConsumerOffsetRequest {
+    pub consumer: WireConsumer,
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub partition_id: Option<u32>,
+    pub offset: u64,
+}
+
+impl WireEncode for StoreConsumerOffsetRequest {
+    fn encoded_size(&self) -> usize {
+        self.consumer.encoded_size()
+            + self.stream_id.encoded_size()
+            + self.topic_id.encoded_size()
+            + 1
+            + 4
+            + 8
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.consumer.encode(buf);
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        if let Some(pid) = self.partition_id {
+            buf.put_u8(1);
+            buf.put_u32_le(pid);
+        } else {
+            buf.put_u8(0);
+            buf.put_u32_le(0);
+        }
+        buf.put_u64_le(self.offset);
+    }
+}
+
+impl WireDecode for StoreConsumerOffsetRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let mut pos = 0;
+        let (consumer, n) = WireConsumer::decode(&buf[pos..])?;
+        pos += n;
+        let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let partition_flag = read_u8(buf, pos)?;
+        pos += 1;
+        let partition_raw = read_u32_le(buf, pos)?;
+        pos += 4;
+        let partition_id = if partition_flag == 1 {
+            Some(partition_raw)
+        } else {
+            None
+        };
+        let offset = read_u64_le(buf, pos)?;
+        pos += 8;
+        Ok((
+            Self {
+                consumer,
+                stream_id,
+                topic_id,
+                partition_id,
+                offset,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip_with_partition() {
+        let req = StoreConsumerOffsetRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(10),
+            topic_id: WireIdentifier::numeric(20),
+            partition_id: Some(5),
+            offset: 12345,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = StoreConsumerOffsetRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_without_partition() {
+        let req = StoreConsumerOffsetRequest {
+            consumer: WireConsumer::consumer_group(WireIdentifier::numeric(3)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(1),
+            partition_id: None,
+            offset: u64::MAX,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = StoreConsumerOffsetRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named_identifiers() {
+        let req = StoreConsumerOffsetRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::named("my-consumer").unwrap()),
+            stream_id: WireIdentifier::named("stream-1").unwrap(),
+            topic_id: WireIdentifier::named("topic-1").unwrap(),
+            partition_id: Some(0),
+            offset: 0,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = StoreConsumerOffsetRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn offset_encoding() {
+        let req = StoreConsumerOffsetRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(1),
+            partition_id: Some(0),
+            offset: 0x0102_0304_0506_0708,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, _) = StoreConsumerOffsetRequest::decode(&bytes).unwrap();
+        assert_eq!(decoded.offset, 0x0102_0304_0506_0708);
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = StoreConsumerOffsetRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(1),
+            partition_id: Some(1),
+            offset: 100,
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                StoreConsumerOffsetRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/requests/messages/flush_unsaved_buffer.rs b/core/binary_protocol/src/requests/messages/flush_unsaved_buffer.rs
new file mode 100644
index 000000000..0c276dbec
--- /dev/null
+++ b/core/binary_protocol/src/requests/messages/flush_unsaved_buffer.rs
@@ -0,0 +1,155 @@
+// 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.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le};
+use bytes::{BufMut, BytesMut};
+
+/// `FlushUnsavedBuffer` request.
+///
+/// Wire format: `[stream_id][topic_id][partition_id:4 LE][fsync:1]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct FlushUnsavedBufferRequest {
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub partition_id: u32,
+    pub fsync: bool,
+}
+
+impl WireEncode for FlushUnsavedBufferRequest {
+    fn encoded_size(&self) -> usize {
+        self.stream_id.encoded_size() + self.topic_id.encoded_size() + 4 + 1
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        buf.put_u32_le(self.partition_id);
+        buf.put_u8(u8::from(self.fsync));
+    }
+}
+
+impl WireDecode for FlushUnsavedBufferRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let mut pos = 0;
+        let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let partition_id = read_u32_le(buf, pos)?;
+        pos += 4;
+        let fsync = read_u8(buf, pos)? != 0;
+        pos += 1;
+        Ok((
+            Self {
+                stream_id,
+                topic_id,
+                partition_id,
+                fsync,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = FlushUnsavedBufferRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            partition_id: 3,
+            fsync: true,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = FlushUnsavedBufferRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named_identifiers() {
+        let req = FlushUnsavedBufferRequest {
+            stream_id: WireIdentifier::named("stream-1").unwrap(),
+            topic_id: WireIdentifier::named("topic-1").unwrap(),
+            partition_id: 42,
+            fsync: false,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = FlushUnsavedBufferRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn fsync_flag_encoding() {
+        let req_true = FlushUnsavedBufferRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(1),
+            partition_id: 0,
+            fsync: true,
+        };
+        let req_false = FlushUnsavedBufferRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(1),
+            partition_id: 0,
+            fsync: false,
+        };
+        let bytes_true = req_true.to_bytes();
+        let bytes_false = req_false.to_bytes();
+        assert_eq!(*bytes_true.last().unwrap(), 1);
+        assert_eq!(*bytes_false.last().unwrap(), 0);
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = FlushUnsavedBufferRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            partition_id: 3,
+            fsync: true,
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                FlushUnsavedBufferRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn wire_compat_byte_layout() {
+        let req = FlushUnsavedBufferRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            partition_id: 3,
+            fsync: true,
+        };
+        let bytes = req.to_bytes();
+        // stream_id: [1, 4, 1, 0, 0, 0] + topic_id: [1, 4, 2, 0, 0, 0]
+        // + partition_id: [3, 0, 0, 0] + fsync: [1]
+        assert_eq!(
+            &bytes[..],
+            &[1, 4, 1, 0, 0, 0, 1, 4, 2, 0, 0, 0, 3, 0, 0, 0, 1]
+        );
+    }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/requests/messages/mod.rs
similarity index 73%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/requests/messages/mod.rs
index e7b687f1f..6b976e07e 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/requests/messages/mod.rs
@@ -15,4 +15,12 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+pub mod flush_unsaved_buffer;
+pub mod poll_messages;
+pub mod send_messages;
+
+pub use flush_unsaved_buffer::FlushUnsavedBufferRequest;
+pub use poll_messages::PollMessagesRequest;
+pub use send_messages::{
+    RawMessage, SendMessagesEncoder, SendMessagesHeader, SendMessagesMetadataEncoder,
+};
diff --git a/core/binary_protocol/src/requests/messages/poll_messages.rs b/core/binary_protocol/src/requests/messages/poll_messages.rs
new file mode 100644
index 000000000..4c6413cfc
--- /dev/null
+++ b/core/binary_protocol/src/requests/messages/poll_messages.rs
@@ -0,0 +1,220 @@
+// 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.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le};
+use crate::primitives::consumer::WireConsumer;
+use crate::primitives::polling_strategy::WirePollingStrategy;
+use bytes::{BufMut, BytesMut};
+
+/// `PollMessages` request.
+///
+/// Wire format:
+/// ```text
+/// [consumer][stream_id][topic_id][partition_flag:1][partition_id:4 LE]
+/// [strategy:9][count:4 LE][auto_commit:1]
+/// ```
+///
+/// `partition_id` encoding: a u8 flag (1=Some, 0=None) followed by 4 bytes
+/// for the u32 value (0 when None).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PollMessagesRequest {
+    pub consumer: WireConsumer,
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub partition_id: Option<u32>,
+    pub strategy: WirePollingStrategy,
+    pub count: u32,
+    pub auto_commit: bool,
+}
+
+const PARTITION_FLAG_SIZE: usize = 1;
+const PARTITION_VALUE_SIZE: usize = 4;
+const STRATEGY_SIZE: usize = 9;
+const COUNT_SIZE: usize = 4;
+const AUTO_COMMIT_SIZE: usize = 1;
+
+impl WireEncode for PollMessagesRequest {
+    fn encoded_size(&self) -> usize {
+        self.consumer.encoded_size()
+            + self.stream_id.encoded_size()
+            + self.topic_id.encoded_size()
+            + PARTITION_FLAG_SIZE
+            + PARTITION_VALUE_SIZE
+            + STRATEGY_SIZE
+            + COUNT_SIZE
+            + AUTO_COMMIT_SIZE
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.consumer.encode(buf);
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        if let Some(pid) = self.partition_id {
+            buf.put_u8(1);
+            buf.put_u32_le(pid);
+        } else {
+            buf.put_u8(0);
+            buf.put_u32_le(0);
+        }
+        self.strategy.encode(buf);
+        buf.put_u32_le(self.count);
+        buf.put_u8(u8::from(self.auto_commit));
+    }
+}
+
+impl WireDecode for PollMessagesRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let mut pos = 0;
+        let (consumer, n) = WireConsumer::decode(&buf[pos..])?;
+        pos += n;
+        let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+
+        let partition_flag = read_u8(buf, pos)?;
+        pos += 1;
+        let partition_raw = read_u32_le(buf, pos)?;
+        pos += 4;
+        let partition_id = if partition_flag == 1 {
+            Some(partition_raw)
+        } else {
+            None
+        };
+
+        let (strategy, n) = WirePollingStrategy::decode(&buf[pos..])?;
+        pos += n;
+        let count = read_u32_le(buf, pos)?;
+        pos += 4;
+        let auto_commit = read_u8(buf, pos)? != 0;
+        pos += 1;
+
+        Ok((
+            Self {
+                consumer,
+                stream_id,
+                topic_id,
+                partition_id,
+                strategy,
+                count,
+                auto_commit,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip_with_partition() {
+        let req = PollMessagesRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(10),
+            topic_id: WireIdentifier::numeric(20),
+            partition_id: Some(5),
+            strategy: WirePollingStrategy::offset(100),
+            count: 50,
+            auto_commit: true,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = PollMessagesRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_without_partition() {
+        let req = PollMessagesRequest {
+            consumer: WireConsumer::consumer_group(WireIdentifier::numeric(3)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(1),
+            partition_id: None,
+            strategy: WirePollingStrategy::first(),
+            count: 10,
+            auto_commit: false,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = PollMessagesRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named_identifiers() {
+        let req = PollMessagesRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::named("my-consumer").unwrap()),
+            stream_id: WireIdentifier::named("stream-1").unwrap(),
+            topic_id: WireIdentifier::named("topic-1").unwrap(),
+            partition_id: Some(0),
+            strategy: WirePollingStrategy::offset(0),
+            count: 1,
+            auto_commit: false,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = PollMessagesRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn partition_none_encodes_zero_bytes() {
+        let req = PollMessagesRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(1),
+            partition_id: None,
+            strategy: WirePollingStrategy::first(),
+            count: 1,
+            auto_commit: false,
+        };
+        let bytes = req.to_bytes();
+        // After consumer(7) + stream_id(6) + topic_id(6) = offset 19
+        let partition_offset = req.consumer.encoded_size()
+            + req.stream_id.encoded_size()
+            + req.topic_id.encoded_size();
+        assert_eq!(bytes[partition_offset], 0); // flag = 0
+        assert_eq!(
+            &bytes[partition_offset + 1..partition_offset + 5],
+            &[0, 0, 0, 0]
+        );
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = PollMessagesRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(1),
+            partition_id: Some(1),
+            strategy: WirePollingStrategy::offset(0),
+            count: 1,
+            auto_commit: false,
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                PollMessagesRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/requests/messages/send_messages.rs b/core/binary_protocol/src/requests/messages/send_messages.rs
new file mode 100644
index 000000000..559ce9e0b
--- /dev/null
+++ b/core/binary_protocol/src/requests/messages/send_messages.rs
@@ -0,0 +1,750 @@
+// 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.
+
+//! Zero-copy encoder for `SendMessages` wire format.
+//!
+//! Messages are written directly to the buffer without intermediate allocation.
+
+use crate::codec::{WireDecode, WireEncode, read_u32_le};
+use crate::error::WireError;
+use crate::message_layout::{WIRE_MESSAGE_HEADER_SIZE, WIRE_MESSAGE_INDEX_SIZE};
+use crate::primitives::identifier::WireIdentifier;
+use crate::primitives::partitioning::WirePartitioning;
+use bytes::{BufMut, BytesMut};
+
+/// Borrowed message data for encoding. No allocation needed - the caller
+/// owns the payload and headers buffers.
+pub struct RawMessage<'a> {
+    pub id: u128,
+    pub origin_timestamp: u64,
+    pub headers: Option<&'a [u8]>,
+    pub payload: &'a [u8],
+}
+
+impl RawMessage<'_> {
+    fn wire_size(&self) -> usize {
+        WIRE_MESSAGE_HEADER_SIZE + self.payload.len() + self.headers.map_or(0, <[u8]>::len)
+    }
+
+    /// Write the 64-byte message header to `buf`. Payload and `user_headers` are NOT
+    /// written - the caller sends them separately via vectored I/O.
+    pub fn encode_header(&self, buf: &mut BytesMut) {
+        let headers_len = self.headers.map_or(0, <[u8]>::len);
+        buf.put_u64_le(0); // checksum (server-computed)
+        buf.put_u128_le(self.id);
+        buf.put_u64_le(0); // offset (server-assigned)
+        buf.put_u64_le(0); // timestamp (server-assigned)
+        buf.put_u64_le(self.origin_timestamp);
+        #[allow(clippy::cast_possible_truncation)]
+        {
+            buf.put_u32_le(headers_len as u32);
+            buf.put_u32_le(self.payload.len() as u32);
+        }
+        buf.put_u64_le(0); // reserved
+    }
+}
+
+/// Zero-copy encoder for the `SendMessages` command payload.
+///
+/// Wire layout:
+/// ```text
+/// [metadata_length:u32_le]
+/// [stream_id:variable]
+/// [topic_id:variable]
+/// [partitioning:variable]
+/// [messages_count:u32_le]
+/// [index_array: messages_count * 16 bytes]
+/// [message_data: variable]
+/// ```
+pub struct SendMessagesEncoder;
+
+impl SendMessagesEncoder {
+    #[must_use]
+    pub fn encoded_size(
+        stream_id: &WireIdentifier,
+        topic_id: &WireIdentifier,
+        partitioning: &WirePartitioning,
+        messages: &[RawMessage<'_>],
+    ) -> usize {
+        let metadata_inner =
+            stream_id.encoded_size() + topic_id.encoded_size() + partitioning.encoded_size() + 4;
+        let index_total = messages.len() * WIRE_MESSAGE_INDEX_SIZE;
+        let messages_total: usize = messages.iter().map(RawMessage::wire_size).sum();
+        4 + metadata_inner + index_total + messages_total
+    }
+
+    pub fn encode(
+        buf: &mut BytesMut,
+        stream_id: &WireIdentifier,
+        topic_id: &WireIdentifier,
+        partitioning: &WirePartitioning,
+        messages: &[RawMessage<'_>],
+    ) {
+        let metadata_inner =
+            stream_id.encoded_size() + topic_id.encoded_size() + partitioning.encoded_size() + 4;
+
+        #[allow(clippy::cast_possible_truncation)]
+        buf.put_u32_le(metadata_inner as u32);
+
+        stream_id.encode(buf);
+        topic_id.encode(buf);
+        partitioning.encode(buf);
+
+        #[allow(clippy::cast_possible_truncation)]
+        buf.put_u32_le(messages.len() as u32);
+
+        // Index array: cumulative sizes for each message
+        let mut cumulative_size: u32 = 0;
+        for msg in messages {
+            #[allow(clippy::cast_possible_truncation)]
+            {
+                cumulative_size += msg.wire_size() as u32;
+            }
+            // bytes 0-3: zero
+            buf.put_u32_le(0);
+            // bytes 4-7: cumulative size
+            buf.put_u32_le(cumulative_size);
+            // bytes 8-15: zero
+            buf.put_u64_le(0);
+        }
+
+        // Message data: header(64) + payload + optional user_headers
+        for msg in messages {
+            let headers_len = msg.headers.map_or(0, <[u8]>::len);
+
+            buf.put_u64_le(0); // checksum (server-computed)
+            buf.put_u128_le(msg.id);
+            buf.put_u64_le(0); // offset (server-assigned)
+            buf.put_u64_le(0); // timestamp (server-assigned)
+            buf.put_u64_le(msg.origin_timestamp);
+            #[allow(clippy::cast_possible_truncation)]
+            {
+                buf.put_u32_le(headers_len as u32);
+                buf.put_u32_le(msg.payload.len() as u32);
+            }
+            buf.put_u64_le(0); // reserved
+            buf.put_slice(msg.payload);
+            if let Some(headers) = msg.headers {
+                buf.put_slice(headers);
+            }
+        }
+    }
+}
+
+/// Metadata-only decoder for the `SendMessages` command.
+///
+/// Parses routing metadata (stream, topic, partitioning, message count)
+/// without touching message payloads. The server uses this to extract
+/// routing info before reading message data into pooled buffers.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SendMessagesHeader {
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub partitioning: WirePartitioning,
+    pub messages_count: u32,
+}
+
+impl SendMessagesHeader {
+    /// Size of the encoded metadata fields (the value written as the
+    /// `metadata_length` prefix on the wire).
+    #[must_use]
+    pub fn metadata_length(&self) -> usize {
+        self.stream_id.encoded_size()
+            + self.topic_id.encoded_size()
+            + self.partitioning.encoded_size()
+            + 4
+    }
+}
+
+impl WireEncode for SendMessagesHeader {
+    fn encoded_size(&self) -> usize {
+        self.metadata_length()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        self.partitioning.encode(buf);
+        buf.put_u32_le(self.messages_count);
+    }
+}
+
+impl WireDecode for SendMessagesHeader {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
+        let (topic_id, consumed) = WireIdentifier::decode(&buf[pos..])?;
+        pos += consumed;
+        let (partitioning, consumed) = WirePartitioning::decode(&buf[pos..])?;
+        pos += consumed;
+        let messages_count = read_u32_le(buf, pos)?;
+        pos += 4;
+        Ok((
+            Self {
+                stream_id,
+                topic_id,
+                partitioning,
+                messages_count,
+            },
+            pos,
+        ))
+    }
+}
+
+/// Vectored I/O encoder for `SendMessages`.
+///
+/// Writes metadata + index array but stops before message frame data,
+/// enabling the caller to compose `[header_buf | msg_frames...]` via writev.
+pub struct SendMessagesMetadataEncoder;
+
+impl SendMessagesMetadataEncoder {
+    /// Exact size of metadata + indexes (everything except message frame data).
+    #[must_use]
+    pub fn header_size(
+        stream_id: &WireIdentifier,
+        topic_id: &WireIdentifier,
+        partitioning: &WirePartitioning,
+        messages_count: usize,
+    ) -> usize {
+        let metadata_inner =
+            stream_id.encoded_size() + topic_id.encoded_size() + partitioning.encoded_size() + 4;
+        4 + metadata_inner + messages_count * WIRE_MESSAGE_INDEX_SIZE
+    }
+
+    /// Encode `metadata_length` + metadata fields + index array into `buf`.
+    ///
+    /// Does NOT write message frame data - the caller sends that via vectored I/O.
+    pub fn encode_header(
+        buf: &mut BytesMut,
+        stream_id: &WireIdentifier,
+        topic_id: &WireIdentifier,
+        partitioning: &WirePartitioning,
+        messages: &[RawMessage<'_>],
+    ) {
+        let metadata_inner =
+            stream_id.encoded_size() + topic_id.encoded_size() + partitioning.encoded_size() + 4;
+
+        #[allow(clippy::cast_possible_truncation)]
+        buf.put_u32_le(metadata_inner as u32);
+
+        stream_id.encode(buf);
+        topic_id.encode(buf);
+        partitioning.encode(buf);
+
+        #[allow(clippy::cast_possible_truncation)]
+        buf.put_u32_le(messages.len() as u32);
+
+        let mut cumulative_size: u32 = 0;
+        for msg in messages {
+            #[allow(clippy::cast_possible_truncation)]
+            {
+                cumulative_size += msg.wire_size() as u32;
+            }
+            buf.put_u32_le(0);
+            buf.put_u32_le(cumulative_size);
+            buf.put_u64_le(0);
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn numeric_id(id: u32) -> WireIdentifier {
+        WireIdentifier::numeric(id)
+    }
+
+    #[test]
+    fn encode_single_message_no_headers() {
+        let stream_id = numeric_id(1);
+        let topic_id = numeric_id(2);
+        let partitioning = WirePartitioning::Balanced;
+        let payload = b"hello";
+        let messages = [RawMessage {
+            id: 100,
+            origin_timestamp: 999,
+            headers: None,
+            payload: payload.as_slice(),
+        }];
+
+        let size =
+            SendMessagesEncoder::encoded_size(&stream_id, &topic_id, &partitioning, &messages);
+        let mut buf = BytesMut::with_capacity(size);
+        SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+
+        assert_eq!(buf.len(), size);
+    }
+
+    #[test]
+    fn encode_single_message_with_headers() {
+        let stream_id = numeric_id(1);
+        let topic_id = numeric_id(2);
+        let partitioning = WirePartitioning::PartitionId(5);
+        let payload = b"world";
+        let headers = b"key:val";
+        let messages = [RawMessage {
+            id: 200,
+            origin_timestamp: 1000,
+            headers: Some(headers.as_slice()),
+            payload: payload.as_slice(),
+        }];
+
+        let size =
+            SendMessagesEncoder::encoded_size(&stream_id, &topic_id, &partitioning, &messages);
+        let mut buf = BytesMut::with_capacity(size);
+        SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+
+        assert_eq!(buf.len(), size);
+    }
+
+    #[test]
+    fn encode_multiple_messages() {
+        let stream_id = numeric_id(10);
+        let topic_id = numeric_id(20);
+        let partitioning = WirePartitioning::MessagesKey(b"user-1".to_vec());
+        let messages = [
+            RawMessage {
+                id: 1,
+                origin_timestamp: 100,
+                headers: None,
+                payload: b"msg-1",
+            },
+            RawMessage {
+                id: 2,
+                origin_timestamp: 200,
+                headers: Some(b"h2"),
+                payload: b"msg-2",
+            },
+            RawMessage {
+                id: 3,
+                origin_timestamp: 300,
+                headers: None,
+                payload: b"msg-3",
+            },
+        ];
+
+        let size =
+            SendMessagesEncoder::encoded_size(&stream_id, &topic_id, &partitioning, &messages);
+        let mut buf = BytesMut::with_capacity(size);
+        SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+
+        assert_eq!(buf.len(), size);
+    }
+
+    #[test]
+    fn verify_metadata_length_field() {
+        let stream_id = numeric_id(1);
+        let topic_id = numeric_id(2);
+        let partitioning = WirePartitioning::Balanced;
+        let messages = [RawMessage {
+            id: 1,
+            origin_timestamp: 0,
+            headers: None,
+            payload: b"x",
+        }];
+
+        let mut buf = BytesMut::with_capacity(256);
+        SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+
+        let metadata_len = u32::from_le_bytes(buf[0..4].try_into().unwrap()) as usize;
+        let expected =
+            stream_id.encoded_size() + topic_id.encoded_size() + partitioning.encoded_size() + 4;
+        assert_eq!(metadata_len, expected);
+    }
+
+    #[test]
+    fn verify_index_entries() {
+        let stream_id = numeric_id(1);
+        let topic_id = numeric_id(2);
+        let partitioning = WirePartitioning::Balanced;
+        let messages = [
+            RawMessage {
+                id: 1,
+                origin_timestamp: 0,
+                headers: None,
+                payload: b"aaaa", // wire_size = 64 + 4 = 68
+            },
+            RawMessage {
+                id: 2,
+                origin_timestamp: 0,
+                headers: Some(b"hh"), // wire_size = 64 + 3 + 2 = 69
+                payload: b"bbb",
+            },
+        ];
+
+        let mut buf = BytesMut::with_capacity(512);
+        SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+
+        // Index starts after: 4 (metadata_len) + stream_id(6) + topic_id(6) + partitioning(2) + 4 (msg_count)
+        let index_start = 4 + 6 + 6 + 2 + 4;
+
+        // First index entry: cumulative = 68
+        let first_zero = u32::from_le_bytes(buf[index_start..index_start + 4].try_into().unwrap());
+        let first_cum =
+            u32::from_le_bytes(buf[index_start + 4..index_start + 8].try_into().unwrap());
+        assert_eq!(first_zero, 0);
+        assert_eq!(first_cum, 68);
+
+        // Second index entry: cumulative = 68 + 69 = 137
+        let second_start = index_start + WIRE_MESSAGE_INDEX_SIZE;
+        let second_zero =
+            u32::from_le_bytes(buf[second_start..second_start + 4].try_into().unwrap());
+        let second_cum =
+            u32::from_le_bytes(buf[second_start + 4..second_start + 8].try_into().unwrap());
+        assert_eq!(second_zero, 0);
+        assert_eq!(second_cum, 137);
+    }
+
+    #[test]
+    fn verify_message_header_layout() {
+        let stream_id = numeric_id(1);
+        let topic_id = numeric_id(2);
+        let partitioning = WirePartitioning::Balanced;
+        let messages = [RawMessage {
+            id: 42,
+            origin_timestamp: 777,
+            headers: Some(b"hdr"),
+            payload: b"pay",
+        }];
+
+        let mut buf = BytesMut::with_capacity(256);
+        SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+
+        // Message data starts after: 4 + 6 + 6 + 2 + 4 + 16 (one index entry)
+        let msg_start = 4 + 6 + 6 + 2 + 4 + 16;
+        let msg = &buf[msg_start..];
+
+        // Header layout matches WIRE_MESSAGE_HEADER_SIZE (64 bytes):
+        // checksum(8) + id(16) + offset(8) + timestamp(8) + origin_ts(8) + hdrs_len(4) + payload_len(4) + reserved(8)
+        let checksum = u64::from_le_bytes(msg[0..8].try_into().unwrap());
+        assert_eq!(checksum, 0);
+
+        let id = u128::from_le_bytes(msg[8..24].try_into().unwrap());
+        assert_eq!(id, 42);
+
+        let offset = u64::from_le_bytes(msg[24..32].try_into().unwrap());
+        assert_eq!(offset, 0);
+
+        let timestamp = u64::from_le_bytes(msg[32..40].try_into().unwrap());
+        assert_eq!(timestamp, 0);
+
+        let origin_ts = u64::from_le_bytes(msg[40..48].try_into().unwrap());
+        assert_eq!(origin_ts, 777);
+
+        let headers_len = u32::from_le_bytes(msg[48..52].try_into().unwrap());
+        assert_eq!(headers_len, 3);
+
+        let payload_len = u32::from_le_bytes(msg[52..56].try_into().unwrap());
+        assert_eq!(payload_len, 3);
+
+        let reserved = u64::from_le_bytes(msg[56..64].try_into().unwrap());
+        assert_eq!(reserved, 0);
+
+        // After the 64-byte header: payload then headers
+        assert_eq!(&msg[64..67], b"pay");
+        assert_eq!(&msg[67..70], b"hdr");
+    }
+
+    #[test]
+    fn empty_payload_message() {
+        let stream_id = numeric_id(1);
+        let topic_id = numeric_id(1);
+        let partitioning = WirePartitioning::Balanced;
+        let messages = [RawMessage {
+            id: 0,
+            origin_timestamp: 0,
+            headers: None,
+            payload: b"",
+        }];
+
+        let size =
+            SendMessagesEncoder::encoded_size(&stream_id, &topic_id, &partitioning, &messages);
+        let mut buf = BytesMut::with_capacity(size);
+        SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+        assert_eq!(buf.len(), size);
+    }
+
+    // -- RawMessage::encode_header --
+
+    #[test]
+    fn raw_message_encode_header_is_64_bytes() {
+        let msg = RawMessage {
+            id: 42,
+            origin_timestamp: 999,
+            headers: Some(b"hdr"),
+            payload: b"pay",
+        };
+        let mut buf = BytesMut::with_capacity(WIRE_MESSAGE_HEADER_SIZE);
+        msg.encode_header(&mut buf);
+        assert_eq!(buf.len(), WIRE_MESSAGE_HEADER_SIZE);
+    }
+
+    #[test]
+    fn raw_message_encode_header_field_values() {
+        let msg = RawMessage {
+            id: 0x1234_5678_9ABC_DEF0_1234_5678_9ABC_DEF0,
+            origin_timestamp: 0xCAFE_BABE,
+            headers: Some(b"hdr"),
+            payload: b"pay",
+        };
+        let mut buf = BytesMut::with_capacity(WIRE_MESSAGE_HEADER_SIZE);
+        msg.encode_header(&mut buf);
+
+        assert_eq!(u64::from_le_bytes(buf[0..8].try_into().unwrap()), 0);
+        assert_eq!(
+            u128::from_le_bytes(buf[8..24].try_into().unwrap()),
+            0x1234_5678_9ABC_DEF0_1234_5678_9ABC_DEF0
+        );
+        assert_eq!(u64::from_le_bytes(buf[24..32].try_into().unwrap()), 0);
+        assert_eq!(u64::from_le_bytes(buf[32..40].try_into().unwrap()), 0);
+        assert_eq!(
+            u64::from_le_bytes(buf[40..48].try_into().unwrap()),
+            0xCAFE_BABE
+        );
+        assert_eq!(u32::from_le_bytes(buf[48..52].try_into().unwrap()), 3);
+        assert_eq!(u32::from_le_bytes(buf[52..56].try_into().unwrap()), 3);
+        assert_eq!(u64::from_le_bytes(buf[56..64].try_into().unwrap()), 0);
+    }
+
+    // -- SendMessagesHeader --
+
+    #[test]
+    fn send_messages_header_roundtrip() {
+        let header = SendMessagesHeader {
+            stream_id: numeric_id(1),
+            topic_id: numeric_id(2),
+            partitioning: WirePartitioning::Balanced,
+            messages_count: 5,
+        };
+        let bytes = header.to_bytes();
+        assert_eq!(bytes.len(), header.encoded_size());
+        let (decoded, consumed) = SendMessagesHeader::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, header);
+    }
+
+    #[test]
+    fn send_messages_header_roundtrip_with_string_ids() {
+        let header = SendMessagesHeader {
+            stream_id: WireIdentifier::named("my-stream").unwrap(),
+            topic_id: WireIdentifier::named("my-topic").unwrap(),
+            partitioning: WirePartitioning::MessagesKey(b"key-1".to_vec()),
+            messages_count: 10,
+        };
+        let bytes = header.to_bytes();
+        let (decoded, consumed) = SendMessagesHeader::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, header);
+    }
+
+    #[test]
+    fn send_messages_header_truncation() {
+        let header = SendMessagesHeader {
+            stream_id: numeric_id(1),
+            topic_id: numeric_id(2),
+            partitioning: WirePartitioning::Balanced,
+            messages_count: 1,
+        };
+        let bytes = header.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                SendMessagesHeader::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn send_messages_header_metadata_length() {
+        let header = SendMessagesHeader {
+            stream_id: numeric_id(1),
+            topic_id: numeric_id(2),
+            partitioning: WirePartitioning::Balanced,
+            messages_count: 3,
+        };
+        // numeric_id(6) + numeric_id(6) + balanced(2) + count(4) = 18
+        assert_eq!(header.metadata_length(), 18);
+        assert_eq!(header.encoded_size(), 18);
+    }
+
+    #[test]
+    fn send_messages_header_cross_validate_with_encoder() {
+        let stream_id = numeric_id(1);
+        let topic_id = numeric_id(2);
+        let partitioning = WirePartitioning::Balanced;
+        let messages = [RawMessage {
+            id: 42,
+            origin_timestamp: 777,
+            headers: Some(b"hdr"),
+            payload: b"pay",
+        }];
+
+        let mut buf = BytesMut::with_capacity(256);
+        SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+
+        let metadata_len = u32::from_le_bytes(buf[0..4].try_into().unwrap()) as usize;
+        let (header, consumed) = SendMessagesHeader::decode(&buf[4..4 + metadata_len]).unwrap();
+        assert_eq!(consumed, metadata_len);
+        assert_eq!(header.stream_id, stream_id);
+        assert_eq!(header.topic_id, topic_id);
+        assert_eq!(header.partitioning, partitioning);
+        assert_eq!(header.messages_count, 1);
+    }
+
+    // -- SendMessagesMetadataEncoder --
+
+    #[test]
+    fn metadata_encoder_header_size_matches_encoded() {
+        let stream_id = numeric_id(1);
+        let topic_id = numeric_id(2);
+        let partitioning = WirePartitioning::Balanced;
+        let messages = [
+            RawMessage {
+                id: 1,
+                origin_timestamp: 100,
+                headers: None,
+                payload: b"msg-1",
+            },
+            RawMessage {
+                id: 2,
+                origin_timestamp: 200,
+                headers: Some(b"h"),
+                payload: b"msg-2",
+            },
+        ];
+
+        let expected_size = SendMessagesMetadataEncoder::header_size(
+            &stream_id,
+            &topic_id,
+            &partitioning,
+            messages.len(),
+        );
+        let mut buf = BytesMut::with_capacity(expected_size);
+        SendMessagesMetadataEncoder::encode_header(
+            &mut buf,
+            &stream_id,
+            &topic_id,
+            &partitioning,
+            &messages,
+        );
+        assert_eq!(buf.len(), expected_size);
+    }
+
+    #[test]
+    fn metadata_encoder_concat_matches_full_encoder() {
+        let stream_id = numeric_id(1);
+        let topic_id = numeric_id(2);
+        let partitioning = WirePartitioning::Balanced;
+        let messages = [
+            RawMessage {
+                id: 1,
+                origin_timestamp: 100,
+                headers: None,
+                payload: b"aaa",
+            },
+            RawMessage {
+                id: 2,
+                origin_timestamp: 200,
+                headers: Some(b"hh"),
+                payload: b"bbb",
+            },
+        ];
+
+        let full_size =
+            SendMessagesEncoder::encoded_size(&stream_id, &topic_id, &partitioning, &messages);
+        let mut full_buf = BytesMut::with_capacity(full_size);
+        SendMessagesEncoder::encode(
+            &mut full_buf,
+            &stream_id,
+            &topic_id,
+            &partitioning,
+            &messages,
+        );
+
+        let mut vec_buf = BytesMut::with_capacity(full_size);
+        SendMessagesMetadataEncoder::encode_header(
+            &mut vec_buf,
+            &stream_id,
+            &topic_id,
+            &partitioning,
+            &messages,
+        );
+        for msg in &messages {
+            msg.encode_header(&mut vec_buf);
+            vec_buf.put_slice(msg.payload);
+            if let Some(headers) = msg.headers {
+                vec_buf.put_slice(headers);
+            }
+        }
+
+        assert_eq!(vec_buf.len(), full_buf.len());
+        assert_eq!(&vec_buf[..], &full_buf[..]);
+    }
+
+    // -- Cross-validation: encoder -> header decoder -> iterator --
+
+    #[test]
+    fn cross_validation_encoder_to_iterator() {
+        use crate::message_view::WireMessageIterator;
+
+        let stream_id = WireIdentifier::numeric(1);
+        let topic_id = WireIdentifier::numeric(2);
+        let partitioning = WirePartitioning::Balanced;
+        let messages = [
+            RawMessage {
+                id: 100,
+                origin_timestamp: 1000,
+                headers: None,
+                payload: b"first",
+            },
+            RawMessage {
+                id: 200,
+                origin_timestamp: 2000,
+                headers: Some(b"hdr"),
+                payload: b"second",
+            },
+        ];
+
+        let size =
+            SendMessagesEncoder::encoded_size(&stream_id, &topic_id, &partitioning, &messages);
+        let mut buf = BytesMut::with_capacity(size);
+        SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+
+        let metadata_len = u32::from_le_bytes(buf[0..4].try_into().unwrap()) as usize;
+        let (header, _) = SendMessagesHeader::decode(&buf[4..4 + metadata_len]).unwrap();
+        assert_eq!(header.messages_count, 2);
+
+        let data_offset =
+            4 + metadata_len + (header.messages_count as usize) * WIRE_MESSAGE_INDEX_SIZE;
+        let message_data = &buf[data_offset..];
+
+        let views: Vec<_> = WireMessageIterator::new(message_data, header.messages_count)
+            .collect::<Result<Vec<_>, _>>()
+            .unwrap();
+        assert_eq!(views.len(), 2);
+
+        assert_eq!(views[0].id(), 100);
+        assert_eq!(views[0].origin_timestamp(), 1000);
+        assert_eq!(views[0].payload(), b"first");
+        assert_eq!(views[0].user_headers(), b"");
+
+        assert_eq!(views[1].id(), 200);
+        assert_eq!(views[1].origin_timestamp(), 2000);
+        assert_eq!(views[1].payload(), b"second");
+        assert_eq!(views[1].user_headers(), b"hdr");
+    }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/requests/mod.rs
index e7b687f1f..14b72cc61 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/requests/mod.rs
@@ -15,4 +15,13 @@
 // specific language governing permissions and limitations
 // under the License.
 
+pub mod consumer_groups;
+pub mod consumer_offsets;
+pub mod messages;
+pub mod partitions;
+pub mod personal_access_tokens;
+pub mod segments;
 pub mod streams;
+pub mod system;
+pub mod topics;
+pub mod users;
diff --git a/core/binary_protocol/src/requests/partitions/create_partitions.rs b/core/binary_protocol/src/requests/partitions/create_partitions.rs
new file mode 100644
index 000000000..a3edb4f9b
--- /dev/null
+++ b/core/binary_protocol/src/requests/partitions/create_partitions.rs
@@ -0,0 +1,118 @@
+// 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.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u32_le};
+use bytes::{BufMut, BytesMut};
+
+/// `CreatePartitions` request.
+///
+/// Wire format: `[stream_id:WireIdentifier][topic_id:WireIdentifier][partitions_count:u32_le]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CreatePartitionsRequest {
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub partitions_count: u32,
+}
+
+impl WireEncode for CreatePartitionsRequest {
+    fn encoded_size(&self) -> usize {
+        self.stream_id.encoded_size() + self.topic_id.encoded_size() + 4
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        buf.put_u32_le(self.partitions_count);
+    }
+}
+
+impl WireDecode for CreatePartitionsRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
+        let (topic_id, consumed) = WireIdentifier::decode(&buf[pos..])?;
+        pos += consumed;
+        let partitions_count = read_u32_le(buf, pos)?;
+        pos += 4;
+        Ok((
+            Self {
+                stream_id,
+                topic_id,
+                partitions_count,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = CreatePartitionsRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            partitions_count: 5,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = CreatePartitionsRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named() {
+        let req = CreatePartitionsRequest {
+            stream_id: WireIdentifier::named("stream").unwrap(),
+            topic_id: WireIdentifier::named("topic").unwrap(),
+            partitions_count: 10,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = CreatePartitionsRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = CreatePartitionsRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            partitions_count: 3,
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                CreatePartitionsRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn encoded_size_matches_output() {
+        let req = CreatePartitionsRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            partitions_count: 7,
+        };
+        assert_eq!(req.encoded_size(), req.to_bytes().len());
+    }
+}
diff --git a/core/binary_protocol/src/requests/partitions/delete_partitions.rs b/core/binary_protocol/src/requests/partitions/delete_partitions.rs
new file mode 100644
index 000000000..5f91b88a3
--- /dev/null
+++ b/core/binary_protocol/src/requests/partitions/delete_partitions.rs
@@ -0,0 +1,118 @@
+// 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.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u32_le};
+use bytes::{BufMut, BytesMut};
+
+/// `DeletePartitions` request.
+///
+/// Wire format: `[stream_id:WireIdentifier][topic_id:WireIdentifier][partitions_count:u32_le]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DeletePartitionsRequest {
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub partitions_count: u32,
+}
+
+impl WireEncode for DeletePartitionsRequest {
+    fn encoded_size(&self) -> usize {
+        self.stream_id.encoded_size() + self.topic_id.encoded_size() + 4
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        buf.put_u32_le(self.partitions_count);
+    }
+}
+
+impl WireDecode for DeletePartitionsRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
+        let (topic_id, consumed) = WireIdentifier::decode(&buf[pos..])?;
+        pos += consumed;
+        let partitions_count = read_u32_le(buf, pos)?;
+        pos += 4;
+        Ok((
+            Self {
+                stream_id,
+                topic_id,
+                partitions_count,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = DeletePartitionsRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            partitions_count: 3,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = DeletePartitionsRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named() {
+        let req = DeletePartitionsRequest {
+            stream_id: WireIdentifier::named("stream").unwrap(),
+            topic_id: WireIdentifier::named("topic").unwrap(),
+            partitions_count: 2,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = DeletePartitionsRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = DeletePartitionsRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            partitions_count: 3,
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                DeletePartitionsRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn encoded_size_matches_output() {
+        let req = DeletePartitionsRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            partitions_count: 5,
+        };
+        assert_eq!(req.encoded_size(), req.to_bytes().len());
+    }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/requests/partitions/mod.rs
similarity index 83%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/requests/partitions/mod.rs
index e7b687f1f..8a5d0a1a1 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/requests/partitions/mod.rs
@@ -15,4 +15,8 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+pub mod create_partitions;
+pub mod delete_partitions;
+
+pub use create_partitions::CreatePartitionsRequest;
+pub use delete_partitions::DeletePartitionsRequest;
diff --git a/core/binary_protocol/src/requests/personal_access_tokens/create_personal_access_token.rs b/core/binary_protocol/src/requests/personal_access_tokens/create_personal_access_token.rs
new file mode 100644
index 000000000..46dda9bc7
--- /dev/null
+++ b/core/binary_protocol/src/requests/personal_access_tokens/create_personal_access_token.rs
@@ -0,0 +1,115 @@
+// 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.
+
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode, read_u64_le};
+use crate::primitives::identifier::WireName;
+use bytes::{BufMut, BytesMut};
+
+/// `CreatePersonalAccessToken` request.
+///
+/// Wire format: `[name_len:u8][name:N][expiry:u64_le]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CreatePersonalAccessTokenRequest {
+    pub name: WireName,
+    pub expiry: u64,
+}
+
+impl WireEncode for CreatePersonalAccessTokenRequest {
+    fn encoded_size(&self) -> usize {
+        self.name.encoded_size() + 8
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.name.encode(buf);
+        buf.put_u64_le(self.expiry);
+    }
+}
+
+impl WireDecode for CreatePersonalAccessTokenRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (name, mut pos) = WireName::decode(buf)?;
+        let expiry = read_u64_le(buf, pos)?;
+        pos += 8;
+        Ok((Self { name, expiry }, pos))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = CreatePersonalAccessTokenRequest {
+            name: WireName::new("my-token").unwrap(),
+            expiry: 3600,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = CreatePersonalAccessTokenRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_zero_expiry() {
+        let req = CreatePersonalAccessTokenRequest {
+            name: WireName::new("permanent").unwrap(),
+            expiry: 0,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = CreatePersonalAccessTokenRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_max_expiry() {
+        let req = CreatePersonalAccessTokenRequest {
+            name: WireName::new("t").unwrap(),
+            expiry: u64::MAX,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = CreatePersonalAccessTokenRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn encoded_size_matches_output() {
+        let req = CreatePersonalAccessTokenRequest {
+            name: WireName::new("test-token").unwrap(),
+            expiry: 86400,
+        };
+        assert_eq!(req.encoded_size(), req.to_bytes().len());
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = CreatePersonalAccessTokenRequest {
+            name: WireName::new("tok").unwrap(),
+            expiry: 100,
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                CreatePersonalAccessTokenRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/requests/streams/create_stream.rs b/core/binary_protocol/src/requests/personal_access_tokens/delete_personal_access_token.rs
similarity index 63%
copy from core/binary_protocol/src/requests/streams/create_stream.rs
copy to core/binary_protocol/src/requests/personal_access_tokens/delete_personal_access_token.rs
index fb86fba94..b3a947ebc 100644
--- a/core/binary_protocol/src/requests/streams/create_stream.rs
+++ b/core/binary_protocol/src/requests/personal_access_tokens/delete_personal_access_token.rs
@@ -17,16 +17,16 @@
 
 use crate::WireError;
 use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
+use crate::primitives::identifier::WireName;
 use bytes::BytesMut;
 
-/// `CreateStream` request. Wire format: `[name_len:1][name:N]`
+/// `DeletePersonalAccessToken` request. Wire format: `[name_len:u8][name:N]`
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct CreateStreamRequest {
+pub struct DeletePersonalAccessTokenRequest {
     pub name: WireName,
 }
 
-impl WireEncode for CreateStreamRequest {
+impl WireEncode for DeletePersonalAccessTokenRequest {
     fn encoded_size(&self) -> usize {
         self.name.encoded_size()
     }
@@ -36,7 +36,7 @@ impl WireEncode for CreateStreamRequest {
     }
 }
 
-impl WireDecode for CreateStreamRequest {
+impl WireDecode for DeletePersonalAccessTokenRequest {
     fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
         let (name, consumed) = WireName::decode(buf)?;
         Ok((Self { name }, consumed))
@@ -49,48 +49,34 @@ mod tests {
 
     #[test]
     fn roundtrip() {
-        let req = CreateStreamRequest {
-            name: WireName::new("test-stream").unwrap(),
+        let req = DeletePersonalAccessTokenRequest {
+            name: WireName::new("my-token").unwrap(),
         };
         let bytes = req.to_bytes();
-        assert_eq!(bytes.len(), 1 + 11);
-        let (decoded, consumed) = CreateStreamRequest::decode(&bytes).unwrap();
+        let (decoded, consumed) = DeletePersonalAccessTokenRequest::decode(&bytes).unwrap();
         assert_eq!(consumed, bytes.len());
         assert_eq!(decoded, req);
     }
 
     #[test]
-    fn empty_name_rejected() {
-        let buf = [0u8];
-        assert!(CreateStreamRequest::decode(&buf).is_err());
+    fn encoded_size_matches_output() {
+        let req = DeletePersonalAccessTokenRequest {
+            name: WireName::new("test").unwrap(),
+        };
+        assert_eq!(req.encoded_size(), req.to_bytes().len());
     }
 
     #[test]
     fn truncated_returns_error() {
-        let req = CreateStreamRequest {
-            name: WireName::new("test").unwrap(),
+        let req = DeletePersonalAccessTokenRequest {
+            name: WireName::new("token").unwrap(),
         };
         let bytes = req.to_bytes();
         for i in 0..bytes.len() {
             assert!(
-                CreateStreamRequest::decode(&bytes[..i]).is_err(),
+                DeletePersonalAccessTokenRequest::decode(&bytes[..i]).is_err(),
                 "expected error for truncation at byte {i}"
             );
         }
     }
-
-    #[test]
-    fn wire_compat_byte_layout() {
-        let req = CreateStreamRequest {
-            name: WireName::new("test").unwrap(),
-        };
-        let bytes = req.to_bytes();
-        assert_eq!(&bytes[..], &[4, b't', b'e', b's', b't']);
-    }
-
-    #[test]
-    fn too_long_name_rejected_at_construction() {
-        let long = "a".repeat(256);
-        assert!(WireName::new(long).is_err());
-    }
 }
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/personal_access_tokens/get_personal_access_tokens.rs
similarity index 66%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/personal_access_tokens/get_personal_access_tokens.rs
index 7b062a665..7cf688b60 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/personal_access_tokens/get_personal_access_tokens.rs
@@ -15,17 +15,15 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
-
 use crate::WireError;
 use crate::codec::{WireDecode, WireEncode};
 use bytes::BytesMut;
 
-/// Marker type for commands that return an empty response payload.
+/// `GetPersonalAccessTokens` request. Wire format: empty.
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct GetPersonalAccessTokensRequest;
 
-impl WireEncode for EmptyResponse {
+impl WireEncode for GetPersonalAccessTokensRequest {
     fn encoded_size(&self) -> usize {
         0
     }
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
     fn encode(&self, _buf: &mut BytesMut) {}
 }
 
-impl WireDecode for EmptyResponse {
+impl WireDecode for GetPersonalAccessTokensRequest {
     fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
         Ok((Self, 0))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = GetPersonalAccessTokensRequest;
+        let bytes = req.to_bytes();
+        assert!(bytes.is_empty());
+        let (decoded, consumed) = GetPersonalAccessTokensRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, 0);
+        assert_eq!(decoded, req);
+    }
+}
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/personal_access_tokens/login_with_personal_access_token.rs
similarity index 58%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/requests/personal_access_tokens/login_with_personal_access_token.rs
index a8085f099..1d6982962 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/personal_access_tokens/login_with_personal_access_token.rs
@@ -16,35 +16,30 @@
 // under the License.
 
 use crate::WireError;
-use crate::WireIdentifier;
 use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
+use crate::primitives::identifier::WireName;
 use bytes::BytesMut;
 
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// `LoginWithPersonalAccessToken` request. Wire format: `[token_len:u8][token:N]`
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
-    pub stream_id: WireIdentifier,
-    pub name: WireName,
+pub struct LoginWithPersonalAccessTokenRequest {
+    pub token: WireName,
 }
 
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for LoginWithPersonalAccessTokenRequest {
     fn encoded_size(&self) -> usize {
-        self.stream_id.encoded_size() + self.name.encoded_size()
+        self.token.encoded_size()
     }
 
     fn encode(&self, buf: &mut BytesMut) {
-        self.stream_id.encode(buf);
-        self.name.encode(buf);
+        self.token.encode(buf);
     }
 }
 
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for LoginWithPersonalAccessTokenRequest {
     fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
-        let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
-        let (name, consumed) = WireName::decode(&buf[pos..])?;
-        pos += consumed;
-        Ok((Self { stream_id, name }, pos))
+        let (token, consumed) = WireName::decode(buf)?;
+        Ok((Self { token }, consumed))
     }
 }
 
@@ -54,26 +49,32 @@ mod tests {
 
     #[test]
     fn roundtrip() {
-        let req = UpdateStreamRequest {
-            stream_id: WireIdentifier::named("old-name").unwrap(),
-            name: WireName::new("new-name").unwrap(),
+        let req = LoginWithPersonalAccessTokenRequest {
+            token: WireName::new("abc123def456").unwrap(),
         };
         let bytes = req.to_bytes();
-        let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+        let (decoded, consumed) = LoginWithPersonalAccessTokenRequest::decode(&bytes).unwrap();
         assert_eq!(consumed, bytes.len());
         assert_eq!(decoded, req);
     }
 
+    #[test]
+    fn encoded_size_matches_output() {
+        let req = LoginWithPersonalAccessTokenRequest {
+            token: WireName::new("token-value").unwrap(),
+        };
+        assert_eq!(req.encoded_size(), req.to_bytes().len());
+    }
+
     #[test]
     fn truncated_returns_error() {
-        let req = UpdateStreamRequest {
-            stream_id: WireIdentifier::numeric(1),
-            name: WireName::new("test").unwrap(),
+        let req = LoginWithPersonalAccessTokenRequest {
+            token: WireName::new("tok").unwrap(),
         };
         let bytes = req.to_bytes();
         for i in 0..bytes.len() {
             assert!(
-                UpdateStreamRequest::decode(&bytes[..i]).is_err(),
+                LoginWithPersonalAccessTokenRequest::decode(&bytes[..i]).is_err(),
                 "expected error for truncation at byte {i}"
             );
         }
diff --git a/core/binary_protocol/src/frame.rs b/core/binary_protocol/src/requests/personal_access_tokens/mod.rs
similarity index 64%
copy from core/binary_protocol/src/frame.rs
copy to core/binary_protocol/src/requests/personal_access_tokens/mod.rs
index b17d0d3f9..8d8a203da 100644
--- a/core/binary_protocol/src/frame.rs
+++ b/core/binary_protocol/src/requests/personal_access_tokens/mod.rs
@@ -15,12 +15,12 @@
 // specific language governing permissions and limitations
 // under the License.
 
-/// Request frame: `[length:4 LE][code:4 LE][payload:N]`
-/// `length` = size of code + payload = 4 + N
-pub const REQUEST_HEADER_SIZE: usize = 4;
+pub mod create_personal_access_token;
+pub mod delete_personal_access_token;
+pub mod get_personal_access_tokens;
+pub mod login_with_personal_access_token;
 
-/// Response frame: `[status:4 LE][length:4 LE][payload:N]`
-pub const RESPONSE_HEADER_SIZE: usize = 8;
-
-/// Status code for a successful response.
-pub const STATUS_OK: u32 = 0;
+pub use create_personal_access_token::CreatePersonalAccessTokenRequest;
+pub use delete_personal_access_token::DeletePersonalAccessTokenRequest;
+pub use get_personal_access_tokens::GetPersonalAccessTokensRequest;
+pub use login_with_personal_access_token::LoginWithPersonalAccessTokenRequest;
diff --git a/core/binary_protocol/src/requests/segments/delete_segments.rs b/core/binary_protocol/src/requests/segments/delete_segments.rs
new file mode 100644
index 000000000..daa54c963
--- /dev/null
+++ b/core/binary_protocol/src/requests/segments/delete_segments.rs
@@ -0,0 +1,128 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u32_le};
+use bytes::{BufMut, BytesMut};
+
+/// `DeleteSegments` request.
+///
+/// Wire format:
+/// `[stream_id:WireIdentifier][topic_id:WireIdentifier][partition_id:u32_le][segments_count:u32_le]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DeleteSegmentsRequest {
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub partition_id: u32,
+    pub segments_count: u32,
+}
+
+impl WireEncode for DeleteSegmentsRequest {
+    fn encoded_size(&self) -> usize {
+        self.stream_id.encoded_size() + self.topic_id.encoded_size() + 4 + 4
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        buf.put_u32_le(self.partition_id);
+        buf.put_u32_le(self.segments_count);
+    }
+}
+
+impl WireDecode for DeleteSegmentsRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
+        let (topic_id, consumed) = WireIdentifier::decode(&buf[pos..])?;
+        pos += consumed;
+        let partition_id = read_u32_le(buf, pos)?;
+        pos += 4;
+        let segments_count = read_u32_le(buf, pos)?;
+        pos += 4;
+        Ok((
+            Self {
+                stream_id,
+                topic_id,
+                partition_id,
+                segments_count,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = DeleteSegmentsRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            partition_id: 3,
+            segments_count: 10,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = DeleteSegmentsRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named() {
+        let req = DeleteSegmentsRequest {
+            stream_id: WireIdentifier::named("stream").unwrap(),
+            topic_id: WireIdentifier::named("topic").unwrap(),
+            partition_id: 1,
+            segments_count: 5,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = DeleteSegmentsRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = DeleteSegmentsRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            partition_id: 3,
+            segments_count: 10,
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                DeleteSegmentsRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn encoded_size_matches_output() {
+        let req = DeleteSegmentsRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            partition_id: 3,
+            segments_count: 10,
+        };
+        assert_eq!(req.encoded_size(), req.to_bytes().len());
+    }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/requests/segments/mod.rs
similarity index 91%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/requests/segments/mod.rs
index e7b687f1f..20960bc99 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/requests/segments/mod.rs
@@ -15,4 +15,6 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+pub mod delete_segments;
+
+pub use delete_segments::DeleteSegmentsRequest;
diff --git a/core/binary_protocol/src/requests/streams/create_stream.rs b/core/binary_protocol/src/requests/streams/create_stream.rs
index fb86fba94..93af61681 100644
--- a/core/binary_protocol/src/requests/streams/create_stream.rs
+++ b/core/binary_protocol/src/requests/streams/create_stream.rs
@@ -17,7 +17,7 @@
 
 use crate::WireError;
 use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
+use crate::primitives::identifier::WireName;
 use bytes::BytesMut;
 
 /// `CreateStream` request. Wire format: `[name_len:1][name:N]`
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/streams/update_stream.rs
index a8085f099..5fa962981 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/streams/update_stream.rs
@@ -18,7 +18,7 @@
 use crate::WireError;
 use crate::WireIdentifier;
 use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
+use crate::primitives::identifier::WireName;
 use bytes::BytesMut;
 
 /// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
diff --git a/core/binary_protocol/src/requests/streams/create_stream.rs b/core/binary_protocol/src/requests/system/get_client.rs
similarity index 55%
copy from core/binary_protocol/src/requests/streams/create_stream.rs
copy to core/binary_protocol/src/requests/system/get_client.rs
index fb86fba94..ceeb313eb 100644
--- a/core/binary_protocol/src/requests/streams/create_stream.rs
+++ b/core/binary_protocol/src/requests/system/get_client.rs
@@ -16,30 +16,29 @@
 // under the License.
 
 use crate::WireError;
-use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
-use bytes::BytesMut;
+use crate::codec::{WireDecode, WireEncode, read_u32_le};
+use bytes::{BufMut, BytesMut};
 
-/// `CreateStream` request. Wire format: `[name_len:1][name:N]`
+/// `GetClient` request. Wire format: `[client_id:u32_le]`
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct CreateStreamRequest {
-    pub name: WireName,
+pub struct GetClientRequest {
+    pub client_id: u32,
 }
 
-impl WireEncode for CreateStreamRequest {
+impl WireEncode for GetClientRequest {
     fn encoded_size(&self) -> usize {
-        self.name.encoded_size()
+        4
     }
 
     fn encode(&self, buf: &mut BytesMut) {
-        self.name.encode(buf);
+        buf.put_u32_le(self.client_id);
     }
 }
 
-impl WireDecode for CreateStreamRequest {
+impl WireDecode for GetClientRequest {
     fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
-        let (name, consumed) = WireName::decode(buf)?;
-        Ok((Self { name }, consumed))
+        let client_id = read_u32_le(buf, 0)?;
+        Ok((Self { client_id }, 4))
     }
 }
 
@@ -49,48 +48,49 @@ mod tests {
 
     #[test]
     fn roundtrip() {
-        let req = CreateStreamRequest {
-            name: WireName::new("test-stream").unwrap(),
-        };
+        let req = GetClientRequest { client_id: 42 };
         let bytes = req.to_bytes();
-        assert_eq!(bytes.len(), 1 + 11);
-        let (decoded, consumed) = CreateStreamRequest::decode(&bytes).unwrap();
+        let (decoded, consumed) = GetClientRequest::decode(&bytes).unwrap();
         assert_eq!(consumed, bytes.len());
         assert_eq!(decoded, req);
     }
 
     #[test]
-    fn empty_name_rejected() {
-        let buf = [0u8];
-        assert!(CreateStreamRequest::decode(&buf).is_err());
+    fn roundtrip_zero() {
+        let req = GetClientRequest { client_id: 0 };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = GetClientRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, 4);
+        assert_eq!(decoded, req);
     }
 
     #[test]
-    fn truncated_returns_error() {
-        let req = CreateStreamRequest {
-            name: WireName::new("test").unwrap(),
+    fn roundtrip_max() {
+        let req = GetClientRequest {
+            client_id: u32::MAX,
         };
         let bytes = req.to_bytes();
-        for i in 0..bytes.len() {
-            assert!(
-                CreateStreamRequest::decode(&bytes[..i]).is_err(),
-                "expected error for truncation at byte {i}"
-            );
-        }
+        let (decoded, consumed) = GetClientRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, 4);
+        assert_eq!(decoded, req);
     }
 
     #[test]
     fn wire_compat_byte_layout() {
-        let req = CreateStreamRequest {
-            name: WireName::new("test").unwrap(),
-        };
+        let req = GetClientRequest { client_id: 1 };
         let bytes = req.to_bytes();
-        assert_eq!(&bytes[..], &[4, b't', b'e', b's', b't']);
+        assert_eq!(&bytes[..], &[1, 0, 0, 0]);
     }
 
     #[test]
-    fn too_long_name_rejected_at_construction() {
-        let long = "a".repeat(256);
-        assert!(WireName::new(long).is_err());
+    fn truncated_returns_error() {
+        let req = GetClientRequest { client_id: 1 };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                GetClientRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
     }
 }
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/system/get_clients.rs
similarity index 69%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/system/get_clients.rs
index 7b062a665..3aeed3a4d 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/system/get_clients.rs
@@ -15,17 +15,15 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
-
 use crate::WireError;
 use crate::codec::{WireDecode, WireEncode};
 use bytes::BytesMut;
 
-/// Marker type for commands that return an empty response payload.
+/// `GetClients` request. Wire format: empty.
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct GetClientsRequest;
 
-impl WireEncode for EmptyResponse {
+impl WireEncode for GetClientsRequest {
     fn encoded_size(&self) -> usize {
         0
     }
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
     fn encode(&self, _buf: &mut BytesMut) {}
 }
 
-impl WireDecode for EmptyResponse {
+impl WireDecode for GetClientsRequest {
     fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
         Ok((Self, 0))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = GetClientsRequest;
+        let bytes = req.to_bytes();
+        assert!(bytes.is_empty());
+        let (decoded, consumed) = GetClientsRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, 0);
+        assert_eq!(decoded, req);
+    }
+}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/system/get_cluster_metadata.rs
similarity index 67%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/system/get_cluster_metadata.rs
index 7b062a665..9bd12c113 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/system/get_cluster_metadata.rs
@@ -15,17 +15,15 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
-
 use crate::WireError;
 use crate::codec::{WireDecode, WireEncode};
 use bytes::BytesMut;
 
-/// Marker type for commands that return an empty response payload.
+/// `GetClusterMetadata` request. Wire format: empty.
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct GetClusterMetadataRequest;
 
-impl WireEncode for EmptyResponse {
+impl WireEncode for GetClusterMetadataRequest {
     fn encoded_size(&self) -> usize {
         0
     }
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
     fn encode(&self, _buf: &mut BytesMut) {}
 }
 
-impl WireDecode for EmptyResponse {
+impl WireDecode for GetClusterMetadataRequest {
     fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
         Ok((Self, 0))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = GetClusterMetadataRequest;
+        let bytes = req.to_bytes();
+        assert!(bytes.is_empty());
+        let (decoded, consumed) = GetClusterMetadataRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, 0);
+        assert_eq!(decoded, req);
+    }
+}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/system/get_me.rs
similarity index 70%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/system/get_me.rs
index 7b062a665..7b5f458ef 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/system/get_me.rs
@@ -15,17 +15,15 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
-
 use crate::WireError;
 use crate::codec::{WireDecode, WireEncode};
 use bytes::BytesMut;
 
-/// Marker type for commands that return an empty response payload.
+/// `GetMe` request. Wire format: empty.
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct GetMeRequest;
 
-impl WireEncode for EmptyResponse {
+impl WireEncode for GetMeRequest {
     fn encoded_size(&self) -> usize {
         0
     }
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
     fn encode(&self, _buf: &mut BytesMut) {}
 }
 
-impl WireDecode for EmptyResponse {
+impl WireDecode for GetMeRequest {
     fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
         Ok((Self, 0))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = GetMeRequest;
+        let bytes = req.to_bytes();
+        assert!(bytes.is_empty());
+        let (decoded, consumed) = GetMeRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, 0);
+        assert_eq!(decoded, req);
+    }
+}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/system/get_snapshot.rs
similarity index 69%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/system/get_snapshot.rs
index 7b062a665..4509158ff 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/system/get_snapshot.rs
@@ -15,17 +15,15 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
-
 use crate::WireError;
 use crate::codec::{WireDecode, WireEncode};
 use bytes::BytesMut;
 
-/// Marker type for commands that return an empty response payload.
+/// `GetSnapshot` request. Wire format: empty.
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct GetSnapshotRequest;
 
-impl WireEncode for EmptyResponse {
+impl WireEncode for GetSnapshotRequest {
     fn encoded_size(&self) -> usize {
         0
     }
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
     fn encode(&self, _buf: &mut BytesMut) {}
 }
 
-impl WireDecode for EmptyResponse {
+impl WireDecode for GetSnapshotRequest {
     fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
         Ok((Self, 0))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = GetSnapshotRequest;
+        let bytes = req.to_bytes();
+        assert!(bytes.is_empty());
+        let (decoded, consumed) = GetSnapshotRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, 0);
+        assert_eq!(decoded, req);
+    }
+}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/system/get_stats.rs
similarity index 70%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/system/get_stats.rs
index 7b062a665..29e98ccfb 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/system/get_stats.rs
@@ -15,17 +15,15 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
-
 use crate::WireError;
 use crate::codec::{WireDecode, WireEncode};
 use bytes::BytesMut;
 
-/// Marker type for commands that return an empty response payload.
+/// `GetStats` request. Wire format: empty.
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct GetStatsRequest;
 
-impl WireEncode for EmptyResponse {
+impl WireEncode for GetStatsRequest {
     fn encoded_size(&self) -> usize {
         0
     }
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
     fn encode(&self, _buf: &mut BytesMut) {}
 }
 
-impl WireDecode for EmptyResponse {
+impl WireDecode for GetStatsRequest {
     fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
         Ok((Self, 0))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = GetStatsRequest;
+        let bytes = req.to_bytes();
+        assert!(bytes.is_empty());
+        let (decoded, consumed) = GetStatsRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, 0);
+        assert_eq!(decoded, req);
+    }
+}
diff --git a/core/binary_protocol/src/frame.rs b/core/binary_protocol/src/requests/system/mod.rs
similarity index 66%
copy from core/binary_protocol/src/frame.rs
copy to core/binary_protocol/src/requests/system/mod.rs
index b17d0d3f9..b490d39c9 100644
--- a/core/binary_protocol/src/frame.rs
+++ b/core/binary_protocol/src/requests/system/mod.rs
@@ -15,12 +15,18 @@
 // specific language governing permissions and limitations
 // under the License.
 
-/// Request frame: `[length:4 LE][code:4 LE][payload:N]`
-/// `length` = size of code + payload = 4 + N
-pub const REQUEST_HEADER_SIZE: usize = 4;
+pub mod get_client;
+pub mod get_clients;
+pub mod get_cluster_metadata;
+pub mod get_me;
+pub mod get_snapshot;
+pub mod get_stats;
+pub mod ping;
 
-/// Response frame: `[status:4 LE][length:4 LE][payload:N]`
-pub const RESPONSE_HEADER_SIZE: usize = 8;
-
-/// Status code for a successful response.
-pub const STATUS_OK: u32 = 0;
+pub use get_client::GetClientRequest;
+pub use get_clients::GetClientsRequest;
+pub use get_cluster_metadata::GetClusterMetadataRequest;
+pub use get_me::GetMeRequest;
+pub use get_snapshot::GetSnapshotRequest;
+pub use get_stats::GetStatsRequest;
+pub use ping::PingRequest;
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/system/ping.rs
similarity index 71%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/system/ping.rs
index 7b062a665..7deb39008 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/system/ping.rs
@@ -15,17 +15,15 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
-
 use crate::WireError;
 use crate::codec::{WireDecode, WireEncode};
 use bytes::BytesMut;
 
-/// Marker type for commands that return an empty response payload.
+/// `Ping` request. Wire format: empty.
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct PingRequest;
 
-impl WireEncode for EmptyResponse {
+impl WireEncode for PingRequest {
     fn encoded_size(&self) -> usize {
         0
     }
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
     fn encode(&self, _buf: &mut BytesMut) {}
 }
 
-impl WireDecode for EmptyResponse {
+impl WireDecode for PingRequest {
     fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
         Ok((Self, 0))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = PingRequest;
+        let bytes = req.to_bytes();
+        assert!(bytes.is_empty());
+        let (decoded, consumed) = PingRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, 0);
+        assert_eq!(decoded, req);
+    }
+}
diff --git a/core/binary_protocol/src/requests/topics/create_topic.rs b/core/binary_protocol/src/requests/topics/create_topic.rs
new file mode 100644
index 000000000..67cc5de17
--- /dev/null
+++ b/core/binary_protocol/src/requests/topics/create_topic.rs
@@ -0,0 +1,147 @@
+// 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.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le, read_u64_le};
+use crate::primitives::identifier::WireName;
+use bytes::{BufMut, BytesMut};
+
+/// `CreateTopic` request.
+///
+/// Wire format:
+/// `[stream_id:WireIdentifier][partitions_count:u32_le][compression_algorithm:u8]
+///  [message_expiry:u64_le][max_topic_size:u64_le][replication_factor:u8][name_len:u8][name:N]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CreateTopicRequest {
+    pub stream_id: WireIdentifier,
+    pub partitions_count: u32,
+    pub compression_algorithm: u8,
+    pub message_expiry: u64,
+    pub max_topic_size: u64,
+    pub replication_factor: u8,
+    pub name: WireName,
+}
+
+const FIXED_FIELDS_SIZE: usize = 4 + 1 + 8 + 8 + 1; // 22 bytes
+
+impl WireEncode for CreateTopicRequest {
+    fn encoded_size(&self) -> usize {
+        self.stream_id.encoded_size() + FIXED_FIELDS_SIZE + self.name.encoded_size()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.stream_id.encode(buf);
+        buf.put_u32_le(self.partitions_count);
+        buf.put_u8(self.compression_algorithm);
+        buf.put_u64_le(self.message_expiry);
+        buf.put_u64_le(self.max_topic_size);
+        buf.put_u8(self.replication_factor);
+        self.name.encode(buf);
+    }
+}
+
+impl WireDecode for CreateTopicRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
+        let partitions_count = read_u32_le(buf, pos)?;
+        pos += 4;
+        let compression_algorithm = read_u8(buf, pos)?;
+        pos += 1;
+        let message_expiry = read_u64_le(buf, pos)?;
+        pos += 8;
+        let max_topic_size = read_u64_le(buf, pos)?;
+        pos += 8;
+        let replication_factor = read_u8(buf, pos)?;
+        pos += 1;
+        let (name, consumed) = WireName::decode(&buf[pos..])?;
+        pos += consumed;
+        Ok((
+            Self {
+                stream_id,
+                partitions_count,
+                compression_algorithm,
+                message_expiry,
+                max_topic_size,
+                replication_factor,
+                name,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn sample_request() -> CreateTopicRequest {
+        CreateTopicRequest {
+            stream_id: WireIdentifier::numeric(1),
+            partitions_count: 3,
+            compression_algorithm: 1,
+            message_expiry: 3600,
+            max_topic_size: 1_000_000,
+            replication_factor: 1,
+            name: WireName::new("orders").unwrap(),
+        }
+    }
+
+    #[test]
+    fn roundtrip() {
+        let req = sample_request();
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = CreateTopicRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named_stream() {
+        let req = CreateTopicRequest {
+            stream_id: WireIdentifier::named("my-stream").unwrap(),
+            partitions_count: 10,
+            compression_algorithm: 2,
+            message_expiry: 0,
+            max_topic_size: u64::MAX,
+            replication_factor: 3,
+            name: WireName::new("events").unwrap(),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = CreateTopicRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = sample_request();
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                CreateTopicRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn encoded_size_matches_output() {
+        let req = sample_request();
+        assert_eq!(req.encoded_size(), req.to_bytes().len());
+    }
+}
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/topics/delete_topic.rs
similarity index 57%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/requests/topics/delete_topic.rs
index a8085f099..d20104301 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/topics/delete_topic.rs
@@ -18,33 +18,38 @@
 use crate::WireError;
 use crate::WireIdentifier;
 use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
 use bytes::BytesMut;
 
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// `DeleteTopic` request. Wire format: `[stream_id:WireIdentifier][topic_id:WireIdentifier]`
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
+pub struct DeleteTopicRequest {
     pub stream_id: WireIdentifier,
-    pub name: WireName,
+    pub topic_id: WireIdentifier,
 }
 
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for DeleteTopicRequest {
     fn encoded_size(&self) -> usize {
-        self.stream_id.encoded_size() + self.name.encoded_size()
+        self.stream_id.encoded_size() + self.topic_id.encoded_size()
     }
 
     fn encode(&self, buf: &mut BytesMut) {
         self.stream_id.encode(buf);
-        self.name.encode(buf);
+        self.topic_id.encode(buf);
     }
 }
 
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for DeleteTopicRequest {
     fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
         let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
-        let (name, consumed) = WireName::decode(&buf[pos..])?;
+        let (topic_id, consumed) = WireIdentifier::decode(&buf[pos..])?;
         pos += consumed;
-        Ok((Self { stream_id, name }, pos))
+        Ok((
+            Self {
+                stream_id,
+                topic_id,
+            },
+            pos,
+        ))
     }
 }
 
@@ -54,26 +59,38 @@ mod tests {
 
     #[test]
     fn roundtrip() {
-        let req = UpdateStreamRequest {
-            stream_id: WireIdentifier::named("old-name").unwrap(),
-            name: WireName::new("new-name").unwrap(),
+        let req = DeleteTopicRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(5),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = DeleteTopicRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named() {
+        let req = DeleteTopicRequest {
+            stream_id: WireIdentifier::named("stream").unwrap(),
+            topic_id: WireIdentifier::named("topic").unwrap(),
         };
         let bytes = req.to_bytes();
-        let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+        let (decoded, consumed) = DeleteTopicRequest::decode(&bytes).unwrap();
         assert_eq!(consumed, bytes.len());
         assert_eq!(decoded, req);
     }
 
     #[test]
     fn truncated_returns_error() {
-        let req = UpdateStreamRequest {
+        let req = DeleteTopicRequest {
             stream_id: WireIdentifier::numeric(1),
-            name: WireName::new("test").unwrap(),
+            topic_id: WireIdentifier::numeric(2),
         };
         let bytes = req.to_bytes();
         for i in 0..bytes.len() {
             assert!(
-                UpdateStreamRequest::decode(&bytes[..i]).is_err(),
+                DeleteTopicRequest::decode(&bytes[..i]).is_err(),
                 "expected error for truncation at byte {i}"
             );
         }
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/topics/get_topic.rs
similarity index 56%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/requests/topics/get_topic.rs
index a8085f099..35cd31ada 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/topics/get_topic.rs
@@ -18,33 +18,38 @@
 use crate::WireError;
 use crate::WireIdentifier;
 use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
 use bytes::BytesMut;
 
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// `GetTopic` request. Wire format: `[stream_id:WireIdentifier][topic_id:WireIdentifier]`
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
+pub struct GetTopicRequest {
     pub stream_id: WireIdentifier,
-    pub name: WireName,
+    pub topic_id: WireIdentifier,
 }
 
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for GetTopicRequest {
     fn encoded_size(&self) -> usize {
-        self.stream_id.encoded_size() + self.name.encoded_size()
+        self.stream_id.encoded_size() + self.topic_id.encoded_size()
     }
 
     fn encode(&self, buf: &mut BytesMut) {
         self.stream_id.encode(buf);
-        self.name.encode(buf);
+        self.topic_id.encode(buf);
     }
 }
 
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for GetTopicRequest {
     fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
         let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
-        let (name, consumed) = WireName::decode(&buf[pos..])?;
+        let (topic_id, consumed) = WireIdentifier::decode(&buf[pos..])?;
         pos += consumed;
-        Ok((Self { stream_id, name }, pos))
+        Ok((
+            Self {
+                stream_id,
+                topic_id,
+            },
+            pos,
+        ))
     }
 }
 
@@ -53,27 +58,39 @@ mod tests {
     use super::*;
 
     #[test]
-    fn roundtrip() {
-        let req = UpdateStreamRequest {
-            stream_id: WireIdentifier::named("old-name").unwrap(),
-            name: WireName::new("new-name").unwrap(),
+    fn roundtrip_numeric() {
+        let req = GetTopicRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = GetTopicRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named() {
+        let req = GetTopicRequest {
+            stream_id: WireIdentifier::named("my-stream").unwrap(),
+            topic_id: WireIdentifier::named("my-topic").unwrap(),
         };
         let bytes = req.to_bytes();
-        let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+        let (decoded, consumed) = GetTopicRequest::decode(&bytes).unwrap();
         assert_eq!(consumed, bytes.len());
         assert_eq!(decoded, req);
     }
 
     #[test]
     fn truncated_returns_error() {
-        let req = UpdateStreamRequest {
+        let req = GetTopicRequest {
             stream_id: WireIdentifier::numeric(1),
-            name: WireName::new("test").unwrap(),
+            topic_id: WireIdentifier::numeric(2),
         };
         let bytes = req.to_bytes();
         for i in 0..bytes.len() {
             assert!(
-                UpdateStreamRequest::decode(&bytes[..i]).is_err(),
+                GetTopicRequest::decode(&bytes[..i]).is_err(),
                 "expected error for truncation at byte {i}"
             );
         }
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/topics/get_topics.rs
similarity index 55%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/requests/topics/get_topics.rs
index a8085f099..1c52fd326 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/topics/get_topics.rs
@@ -18,33 +18,28 @@
 use crate::WireError;
 use crate::WireIdentifier;
 use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
 use bytes::BytesMut;
 
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// `GetTopics` request. Wire format: `[stream_id:WireIdentifier]`
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
+pub struct GetTopicsRequest {
     pub stream_id: WireIdentifier,
-    pub name: WireName,
 }
 
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for GetTopicsRequest {
     fn encoded_size(&self) -> usize {
-        self.stream_id.encoded_size() + self.name.encoded_size()
+        self.stream_id.encoded_size()
     }
 
     fn encode(&self, buf: &mut BytesMut) {
         self.stream_id.encode(buf);
-        self.name.encode(buf);
     }
 }
 
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for GetTopicsRequest {
     fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
-        let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
-        let (name, consumed) = WireName::decode(&buf[pos..])?;
-        pos += consumed;
-        Ok((Self { stream_id, name }, pos))
+        let (stream_id, consumed) = WireIdentifier::decode(buf)?;
+        Ok((Self { stream_id }, consumed))
     }
 }
 
@@ -53,29 +48,24 @@ mod tests {
     use super::*;
 
     #[test]
-    fn roundtrip() {
-        let req = UpdateStreamRequest {
-            stream_id: WireIdentifier::named("old-name").unwrap(),
-            name: WireName::new("new-name").unwrap(),
+    fn roundtrip_numeric() {
+        let req = GetTopicsRequest {
+            stream_id: WireIdentifier::numeric(42),
         };
         let bytes = req.to_bytes();
-        let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+        let (decoded, consumed) = GetTopicsRequest::decode(&bytes).unwrap();
         assert_eq!(consumed, bytes.len());
         assert_eq!(decoded, req);
     }
 
     #[test]
-    fn truncated_returns_error() {
-        let req = UpdateStreamRequest {
-            stream_id: WireIdentifier::numeric(1),
-            name: WireName::new("test").unwrap(),
+    fn roundtrip_named() {
+        let req = GetTopicsRequest {
+            stream_id: WireIdentifier::named("my-stream").unwrap(),
         };
         let bytes = req.to_bytes();
-        for i in 0..bytes.len() {
-            assert!(
-                UpdateStreamRequest::decode(&bytes[..i]).is_err(),
-                "expected error for truncation at byte {i}"
-            );
-        }
+        let (decoded, consumed) = GetTopicsRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
     }
 }
diff --git a/core/binary_protocol/src/frame.rs b/core/binary_protocol/src/requests/topics/mod.rs
similarity index 68%
copy from core/binary_protocol/src/frame.rs
copy to core/binary_protocol/src/requests/topics/mod.rs
index b17d0d3f9..680105ae3 100644
--- a/core/binary_protocol/src/frame.rs
+++ b/core/binary_protocol/src/requests/topics/mod.rs
@@ -15,12 +15,16 @@
 // specific language governing permissions and limitations
 // under the License.
 
-/// Request frame: `[length:4 LE][code:4 LE][payload:N]`
-/// `length` = size of code + payload = 4 + N
-pub const REQUEST_HEADER_SIZE: usize = 4;
+pub mod create_topic;
+pub mod delete_topic;
+pub mod get_topic;
+pub mod get_topics;
+pub mod purge_topic;
+pub mod update_topic;
 
-/// Response frame: `[status:4 LE][length:4 LE][payload:N]`
-pub const RESPONSE_HEADER_SIZE: usize = 8;
-
-/// Status code for a successful response.
-pub const STATUS_OK: u32 = 0;
+pub use create_topic::CreateTopicRequest;
+pub use delete_topic::DeleteTopicRequest;
+pub use get_topic::GetTopicRequest;
+pub use get_topics::GetTopicsRequest;
+pub use purge_topic::PurgeTopicRequest;
+pub use update_topic::UpdateTopicRequest;
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/topics/purge_topic.rs
similarity index 66%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/requests/topics/purge_topic.rs
index a8085f099..7a7bc4d03 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/topics/purge_topic.rs
@@ -18,33 +18,38 @@
 use crate::WireError;
 use crate::WireIdentifier;
 use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
 use bytes::BytesMut;
 
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// `PurgeTopic` request. Wire format: `[stream_id:WireIdentifier][topic_id:WireIdentifier]`
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
+pub struct PurgeTopicRequest {
     pub stream_id: WireIdentifier,
-    pub name: WireName,
+    pub topic_id: WireIdentifier,
 }
 
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for PurgeTopicRequest {
     fn encoded_size(&self) -> usize {
-        self.stream_id.encoded_size() + self.name.encoded_size()
+        self.stream_id.encoded_size() + self.topic_id.encoded_size()
     }
 
     fn encode(&self, buf: &mut BytesMut) {
         self.stream_id.encode(buf);
-        self.name.encode(buf);
+        self.topic_id.encode(buf);
     }
 }
 
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for PurgeTopicRequest {
     fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
         let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
-        let (name, consumed) = WireName::decode(&buf[pos..])?;
+        let (topic_id, consumed) = WireIdentifier::decode(&buf[pos..])?;
         pos += consumed;
-        Ok((Self { stream_id, name }, pos))
+        Ok((
+            Self {
+                stream_id,
+                topic_id,
+            },
+            pos,
+        ))
     }
 }
 
@@ -54,26 +59,26 @@ mod tests {
 
     #[test]
     fn roundtrip() {
-        let req = UpdateStreamRequest {
-            stream_id: WireIdentifier::named("old-name").unwrap(),
-            name: WireName::new("new-name").unwrap(),
+        let req = PurgeTopicRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(3),
         };
         let bytes = req.to_bytes();
-        let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+        let (decoded, consumed) = PurgeTopicRequest::decode(&bytes).unwrap();
         assert_eq!(consumed, bytes.len());
         assert_eq!(decoded, req);
     }
 
     #[test]
     fn truncated_returns_error() {
-        let req = UpdateStreamRequest {
+        let req = PurgeTopicRequest {
             stream_id: WireIdentifier::numeric(1),
-            name: WireName::new("test").unwrap(),
+            topic_id: WireIdentifier::numeric(2),
         };
         let bytes = req.to_bytes();
         for i in 0..bytes.len() {
             assert!(
-                UpdateStreamRequest::decode(&bytes[..i]).is_err(),
+                PurgeTopicRequest::decode(&bytes[..i]).is_err(),
                 "expected error for truncation at byte {i}"
             );
         }
diff --git a/core/binary_protocol/src/requests/topics/update_topic.rs b/core/binary_protocol/src/requests/topics/update_topic.rs
new file mode 100644
index 000000000..f7151242b
--- /dev/null
+++ b/core/binary_protocol/src/requests/topics/update_topic.rs
@@ -0,0 +1,150 @@
+// 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.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u64_le};
+use crate::primitives::identifier::WireName;
+use bytes::{BufMut, BytesMut};
+
+/// `UpdateTopic` request.
+///
+/// Wire format:
+/// `[stream_id:WireIdentifier][topic_id:WireIdentifier][compression_algorithm:u8]
+///  [message_expiry:u64_le][max_topic_size:u64_le][replication_factor:u8][name_len:u8][name:N]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct UpdateTopicRequest {
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub compression_algorithm: u8,
+    pub message_expiry: u64,
+    pub max_topic_size: u64,
+    pub replication_factor: u8,
+    pub name: WireName,
+}
+
+const FIXED_FIELDS_SIZE: usize = 1 + 8 + 8 + 1; // 18 bytes
+
+impl WireEncode for UpdateTopicRequest {
+    fn encoded_size(&self) -> usize {
+        self.stream_id.encoded_size()
+            + self.topic_id.encoded_size()
+            + FIXED_FIELDS_SIZE
+            + self.name.encoded_size()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        buf.put_u8(self.compression_algorithm);
+        buf.put_u64_le(self.message_expiry);
+        buf.put_u64_le(self.max_topic_size);
+        buf.put_u8(self.replication_factor);
+        self.name.encode(buf);
+    }
+}
+
+impl WireDecode for UpdateTopicRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
+        let (topic_id, consumed) = WireIdentifier::decode(&buf[pos..])?;
+        pos += consumed;
+        let compression_algorithm = read_u8(buf, pos)?;
+        pos += 1;
+        let message_expiry = read_u64_le(buf, pos)?;
+        pos += 8;
+        let max_topic_size = read_u64_le(buf, pos)?;
+        pos += 8;
+        let replication_factor = read_u8(buf, pos)?;
+        pos += 1;
+        let (name, name_consumed) = WireName::decode(&buf[pos..])?;
+        pos += name_consumed;
+        Ok((
+            Self {
+                stream_id,
+                topic_id,
+                compression_algorithm,
+                message_expiry,
+                max_topic_size,
+                replication_factor,
+                name,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn sample_request() -> UpdateTopicRequest {
+        UpdateTopicRequest {
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            compression_algorithm: 1,
+            message_expiry: 7200,
+            max_topic_size: 500_000,
+            replication_factor: 2,
+            name: WireName::new("updated-topic").unwrap(),
+        }
+    }
+
+    #[test]
+    fn roundtrip() {
+        let req = sample_request();
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = UpdateTopicRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named_identifiers() {
+        let req = UpdateTopicRequest {
+            stream_id: WireIdentifier::named("stream-a").unwrap(),
+            topic_id: WireIdentifier::named("topic-b").unwrap(),
+            compression_algorithm: 0,
+            message_expiry: 0,
+            max_topic_size: u64::MAX,
+            replication_factor: 1,
+            name: WireName::new("new-name").unwrap(),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = UpdateTopicRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = sample_request();
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                UpdateTopicRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn encoded_size_matches_output() {
+        let req = sample_request();
+        assert_eq!(req.encoded_size(), req.to_bytes().len());
+    }
+}
diff --git a/core/binary_protocol/src/requests/users/change_password.rs b/core/binary_protocol/src/requests/users/change_password.rs
new file mode 100644
index 000000000..ef0a26179
--- /dev/null
+++ b/core/binary_protocol/src/requests/users/change_password.rs
@@ -0,0 +1,131 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_str, read_u8};
+use bytes::{BufMut, BytesMut};
+
+/// `ChangePassword` request.
+///
+/// Wire format:
+/// `[user_id:WireIdentifier][current_password_len:u8][current_password:N]
+///  [new_password_len:u8][new_password:N]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ChangePasswordRequest {
+    pub user_id: WireIdentifier,
+    pub current_password: String,
+    pub new_password: String,
+}
+
+impl WireEncode for ChangePasswordRequest {
+    fn encoded_size(&self) -> usize {
+        self.user_id.encoded_size() + 1 + self.current_password.len() + 1 + self.new_password.len()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.user_id.encode(buf);
+        #[allow(clippy::cast_possible_truncation)]
+        buf.put_u8(self.current_password.len() as u8);
+        buf.put_slice(self.current_password.as_bytes());
+        #[allow(clippy::cast_possible_truncation)]
+        buf.put_u8(self.new_password.len() as u8);
+        buf.put_slice(self.new_password.as_bytes());
+    }
+}
+
+impl WireDecode for ChangePasswordRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (user_id, mut pos) = WireIdentifier::decode(buf)?;
+
+        let current_len = read_u8(buf, pos)? as usize;
+        pos += 1;
+        let current_password = read_str(buf, pos, current_len)?;
+        pos += current_len;
+
+        let new_len = read_u8(buf, pos)? as usize;
+        pos += 1;
+        let new_password = read_str(buf, pos, new_len)?;
+        pos += new_len;
+
+        Ok((
+            Self {
+                user_id,
+                current_password,
+                new_password,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = ChangePasswordRequest {
+            user_id: WireIdentifier::numeric(1),
+            current_password: "old-pass".to_string(),
+            new_password: "new-pass-123".to_string(),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = ChangePasswordRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_named_user() {
+        let req = ChangePasswordRequest {
+            user_id: WireIdentifier::named("admin").unwrap(),
+            current_password: "root".to_string(),
+            new_password: "s3cure!".to_string(),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = ChangePasswordRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn encoded_size_matches_output() {
+        let req = ChangePasswordRequest {
+            user_id: WireIdentifier::numeric(42),
+            current_password: "abc".to_string(),
+            new_password: "xyz123".to_string(),
+        };
+        assert_eq!(req.encoded_size(), req.to_bytes().len());
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = ChangePasswordRequest {
+            user_id: WireIdentifier::numeric(1),
+            current_password: "old".to_string(),
+            new_password: "new".to_string(),
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                ChangePasswordRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/requests/users/create_user.rs b/core/binary_protocol/src/requests/users/create_user.rs
new file mode 100644
index 000000000..4348426e9
--- /dev/null
+++ b/core/binary_protocol/src/requests/users/create_user.rs
@@ -0,0 +1,203 @@
+// 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.
+
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode, read_str, read_u8, read_u32_le};
+use crate::primitives::identifier::WireName;
+use crate::primitives::permissions::WirePermissions;
+use bytes::{BufMut, BytesMut};
+
+/// `CreateUser` request.
+///
+/// Wire format:
+/// `[username_len:u8][username:N][password_len:u8][password:N][status:u8]
+///  [has_permissions:u8][permissions_len:u32_le?][permissions:M?]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CreateUserRequest {
+    pub username: WireName,
+    pub password: String,
+    pub status: u8,
+    pub permissions: Option<WirePermissions>,
+}
+
+impl WireEncode for CreateUserRequest {
+    fn encoded_size(&self) -> usize {
+        self.username.encoded_size()
+            + 1
+            + self.password.len()
+            + 1 // status
+            + 1 // has_permissions
+            + 4 // permissions_len (always present)
+            + self.permissions.as_ref().map_or(0, WireEncode::encoded_size)
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.username.encode(buf);
+        #[allow(clippy::cast_possible_truncation)]
+        buf.put_u8(self.password.len() as u8);
+        buf.put_slice(self.password.as_bytes());
+        buf.put_u8(self.status);
+        if let Some(perms) = &self.permissions {
+            buf.put_u8(1);
+            let perm_bytes = perms.to_bytes();
+            #[allow(clippy::cast_possible_truncation)]
+            buf.put_u32_le(perm_bytes.len() as u32);
+            buf.put_slice(&perm_bytes);
+        } else {
+            buf.put_u8(0);
+            buf.put_u32_le(0);
+        }
+    }
+}
+
+impl WireDecode for CreateUserRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (username, mut pos) = WireName::decode(buf)?;
+
+        let password_len = read_u8(buf, pos)? as usize;
+        pos += 1;
+        let password = read_str(buf, pos, password_len)?;
+        pos += password_len;
+
+        let status = read_u8(buf, pos)?;
+        pos += 1;
+
+        let has_permissions = read_u8(buf, pos)?;
+        pos += 1;
+
+        let perm_len = read_u32_le(buf, pos)? as usize;
+        pos += 4;
+
+        let permissions = if has_permissions == 1 && perm_len > 0 {
+            let (perms, consumed) = WirePermissions::decode(&buf[pos..])?;
+            if consumed != perm_len {
+                return Err(WireError::Validation(format!(
+                    "permissions length mismatch: header says {perm_len}, decoded {consumed}"
+                )));
+            }
+            pos += consumed;
+            Some(perms)
+        } else {
+            None
+        };
+
+        Ok((
+            Self {
+                username,
+                password,
+                status,
+                permissions,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::primitives::permissions::{WireGlobalPermissions, WirePermissions};
+
+    fn sample_permissions() -> WirePermissions {
+        WirePermissions {
+            global: WireGlobalPermissions {
+                manage_servers: true,
+                read_servers: true,
+                manage_users: false,
+                read_users: true,
+                manage_streams: false,
+                read_streams: true,
+                manage_topics: false,
+                read_topics: true,
+                poll_messages: true,
+                send_messages: true,
+            },
+            streams: vec![],
+        }
+    }
+
+    #[test]
+    fn roundtrip_without_permissions() {
+        let req = CreateUserRequest {
+            username: WireName::new("testuser").unwrap(),
+            password: "secret123".to_string(),
+            status: 1,
+            permissions: None,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = CreateUserRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_with_permissions() {
+        let req = CreateUserRequest {
+            username: WireName::new("admin").unwrap(),
+            password: "p@ssw0rd".to_string(),
+            status: 2,
+            permissions: Some(sample_permissions()),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = CreateUserRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn encoded_size_matches_output() {
+        let req = CreateUserRequest {
+            username: WireName::new("user").unwrap(),
+            password: "pw".to_string(),
+            status: 0,
+            permissions: Some(sample_permissions()),
+        };
+        assert_eq!(req.encoded_size(), req.to_bytes().len());
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = CreateUserRequest {
+            username: WireName::new("user").unwrap(),
+            password: "pass".to_string(),
+            status: 1,
+            permissions: None,
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                CreateUserRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn none_permissions_wire_layout() {
+        let req = CreateUserRequest {
+            username: WireName::new("u").unwrap(),
+            password: "p".to_string(),
+            status: 0,
+            permissions: None,
+        };
+        let bytes = req.to_bytes();
+        // username: [1, b'u'] + password: [1, b'p'] + status: [0]
+        // + has_perm: [0] + perm_len: [0,0,0,0]
+        let expected: &[u8] = &[1, b'u', 1, b'p', 0, 0, 0, 0, 0, 0];
+        assert_eq!(&bytes[..], expected);
+    }
+}
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/users/delete_user.rs
similarity index 52%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/requests/users/delete_user.rs
index a8085f099..05a1474c6 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/users/delete_user.rs
@@ -18,33 +18,28 @@
 use crate::WireError;
 use crate::WireIdentifier;
 use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
 use bytes::BytesMut;
 
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// `DeleteUser` request. Wire format: `[user_id:WireIdentifier]`
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
-    pub stream_id: WireIdentifier,
-    pub name: WireName,
+pub struct DeleteUserRequest {
+    pub user_id: WireIdentifier,
 }
 
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for DeleteUserRequest {
     fn encoded_size(&self) -> usize {
-        self.stream_id.encoded_size() + self.name.encoded_size()
+        self.user_id.encoded_size()
     }
 
     fn encode(&self, buf: &mut BytesMut) {
-        self.stream_id.encode(buf);
-        self.name.encode(buf);
+        self.user_id.encode(buf);
     }
 }
 
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for DeleteUserRequest {
     fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
-        let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
-        let (name, consumed) = WireName::decode(&buf[pos..])?;
-        pos += consumed;
-        Ok((Self { stream_id, name }, pos))
+        let (user_id, consumed) = WireIdentifier::decode(buf)?;
+        Ok((Self { user_id }, consumed))
     }
 }
 
@@ -53,29 +48,24 @@ mod tests {
     use super::*;
 
     #[test]
-    fn roundtrip() {
-        let req = UpdateStreamRequest {
-            stream_id: WireIdentifier::named("old-name").unwrap(),
-            name: WireName::new("new-name").unwrap(),
+    fn roundtrip_numeric() {
+        let req = DeleteUserRequest {
+            user_id: WireIdentifier::numeric(5),
         };
         let bytes = req.to_bytes();
-        let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+        let (decoded, consumed) = DeleteUserRequest::decode(&bytes).unwrap();
         assert_eq!(consumed, bytes.len());
         assert_eq!(decoded, req);
     }
 
     #[test]
-    fn truncated_returns_error() {
-        let req = UpdateStreamRequest {
-            stream_id: WireIdentifier::numeric(1),
-            name: WireName::new("test").unwrap(),
+    fn roundtrip_named() {
+        let req = DeleteUserRequest {
+            user_id: WireIdentifier::named("old-user").unwrap(),
         };
         let bytes = req.to_bytes();
-        for i in 0..bytes.len() {
-            assert!(
-                UpdateStreamRequest::decode(&bytes[..i]).is_err(),
-                "expected error for truncation at byte {i}"
-            );
-        }
+        let (decoded, consumed) = DeleteUserRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
     }
 }
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/users/get_user.rs
similarity index 52%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/requests/users/get_user.rs
index a8085f099..74a4f4fd5 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/users/get_user.rs
@@ -18,33 +18,28 @@
 use crate::WireError;
 use crate::WireIdentifier;
 use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
 use bytes::BytesMut;
 
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// `GetUser` request. Wire format: `[user_id:WireIdentifier]`
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
-    pub stream_id: WireIdentifier,
-    pub name: WireName,
+pub struct GetUserRequest {
+    pub user_id: WireIdentifier,
 }
 
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for GetUserRequest {
     fn encoded_size(&self) -> usize {
-        self.stream_id.encoded_size() + self.name.encoded_size()
+        self.user_id.encoded_size()
     }
 
     fn encode(&self, buf: &mut BytesMut) {
-        self.stream_id.encode(buf);
-        self.name.encode(buf);
+        self.user_id.encode(buf);
     }
 }
 
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for GetUserRequest {
     fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
-        let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
-        let (name, consumed) = WireName::decode(&buf[pos..])?;
-        pos += consumed;
-        Ok((Self { stream_id, name }, pos))
+        let (user_id, consumed) = WireIdentifier::decode(buf)?;
+        Ok((Self { user_id }, consumed))
     }
 }
 
@@ -53,29 +48,33 @@ mod tests {
     use super::*;
 
     #[test]
-    fn roundtrip() {
-        let req = UpdateStreamRequest {
-            stream_id: WireIdentifier::named("old-name").unwrap(),
-            name: WireName::new("new-name").unwrap(),
+    fn roundtrip_numeric() {
+        let req = GetUserRequest {
+            user_id: WireIdentifier::numeric(42),
         };
         let bytes = req.to_bytes();
-        let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+        let (decoded, consumed) = GetUserRequest::decode(&bytes).unwrap();
         assert_eq!(consumed, bytes.len());
         assert_eq!(decoded, req);
     }
 
     #[test]
-    fn truncated_returns_error() {
-        let req = UpdateStreamRequest {
-            stream_id: WireIdentifier::numeric(1),
-            name: WireName::new("test").unwrap(),
+    fn roundtrip_named() {
+        let req = GetUserRequest {
+            user_id: WireIdentifier::named("admin").unwrap(),
         };
         let bytes = req.to_bytes();
-        for i in 0..bytes.len() {
-            assert!(
-                UpdateStreamRequest::decode(&bytes[..i]).is_err(),
-                "expected error for truncation at byte {i}"
-            );
-        }
+        let (decoded, consumed) = GetUserRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn wire_compat_numeric_byte_layout() {
+        let req = GetUserRequest {
+            user_id: WireIdentifier::numeric(1),
+        };
+        let bytes = req.to_bytes();
+        assert_eq!(&bytes[..], &[1, 4, 1, 0, 0, 0]);
     }
 }
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/users/get_users.rs
similarity index 70%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/users/get_users.rs
index 7b062a665..edc3f8672 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/users/get_users.rs
@@ -15,17 +15,15 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
-
 use crate::WireError;
 use crate::codec::{WireDecode, WireEncode};
 use bytes::BytesMut;
 
-/// Marker type for commands that return an empty response payload.
+/// `GetUsers` request. Wire format: empty.
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct GetUsersRequest;
 
-impl WireEncode for EmptyResponse {
+impl WireEncode for GetUsersRequest {
     fn encoded_size(&self) -> usize {
         0
     }
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
     fn encode(&self, _buf: &mut BytesMut) {}
 }
 
-impl WireDecode for EmptyResponse {
+impl WireDecode for GetUsersRequest {
     fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
         Ok((Self, 0))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = GetUsersRequest;
+        let bytes = req.to_bytes();
+        assert!(bytes.is_empty());
+        let (decoded, consumed) = GetUsersRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, 0);
+        assert_eq!(decoded, req);
+    }
+}
diff --git a/core/binary_protocol/src/requests/users/login_user.rs b/core/binary_protocol/src/requests/users/login_user.rs
new file mode 100644
index 000000000..ef12a9dc1
--- /dev/null
+++ b/core/binary_protocol/src/requests/users/login_user.rs
@@ -0,0 +1,220 @@
+// 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.
+
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode, read_str, read_u8, read_u32_le};
+use crate::primitives::identifier::WireName;
+use bytes::{BufMut, BytesMut};
+
+/// `LoginUser` request.
+///
+/// Wire format:
+/// `[username_len:u8][username:N][password_len:u8][password:N]
+///  [version_len:u32_le][version:N?][context_len:u32_le][context:N?]`
+///
+/// Both `version_len` and `context_len` are always present on the wire.
+/// A length of 0 means the field is absent.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct LoginUserRequest {
+    pub username: WireName,
+    pub password: String,
+    pub version: Option<String>,
+    pub context: Option<String>,
+}
+
+impl WireEncode for LoginUserRequest {
+    fn encoded_size(&self) -> usize {
+        self.username.encoded_size()
+            + 1
+            + self.password.len()
+            + 4
+            + self.version.as_ref().map_or(0, String::len)
+            + 4
+            + self.context.as_ref().map_or(0, String::len)
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.username.encode(buf);
+        #[allow(clippy::cast_possible_truncation)]
+        buf.put_u8(self.password.len() as u8);
+        buf.put_slice(self.password.as_bytes());
+        match &self.version {
+            Some(v) => {
+                #[allow(clippy::cast_possible_truncation)]
+                buf.put_u32_le(v.len() as u32);
+                buf.put_slice(v.as_bytes());
+            }
+            None => {
+                buf.put_u32_le(0);
+            }
+        }
+        match &self.context {
+            Some(c) => {
+                #[allow(clippy::cast_possible_truncation)]
+                buf.put_u32_le(c.len() as u32);
+                buf.put_slice(c.as_bytes());
+            }
+            None => {
+                buf.put_u32_le(0);
+            }
+        }
+    }
+}
+
+impl WireDecode for LoginUserRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (username, mut pos) = WireName::decode(buf)?;
+
+        let password_len = read_u8(buf, pos)? as usize;
+        pos += 1;
+        let password = read_str(buf, pos, password_len)?;
+        pos += password_len;
+
+        let version_len = read_u32_le(buf, pos)? as usize;
+        pos += 4;
+        let version = if version_len > 0 {
+            let v = read_str(buf, pos, version_len)?;
+            pos += version_len;
+            Some(v)
+        } else {
+            None
+        };
+
+        let context_len = read_u32_le(buf, pos)? as usize;
+        pos += 4;
+        let context = if context_len > 0 {
+            let c = read_str(buf, pos, context_len)?;
+            pos += context_len;
+            Some(c)
+        } else {
+            None
+        };
+
+        Ok((
+            Self {
+                username,
+                password,
+                version,
+                context,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip_full() {
+        let req = LoginUserRequest {
+            username: WireName::new("admin").unwrap(),
+            password: "secret".to_string(),
+            version: Some("1.0.0".to_string()),
+            context: Some("rust-sdk".to_string()),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = LoginUserRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_no_version_no_context() {
+        let req = LoginUserRequest {
+            username: WireName::new("user").unwrap(),
+            password: "pass".to_string(),
+            version: None,
+            context: None,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = LoginUserRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_version_only() {
+        let req = LoginUserRequest {
+            username: WireName::new("user").unwrap(),
+            password: "pw".to_string(),
+            version: Some("2.3.4".to_string()),
+            context: None,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = LoginUserRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_context_only() {
+        let req = LoginUserRequest {
+            username: WireName::new("user").unwrap(),
+            password: "pw".to_string(),
+            version: None,
+            context: Some("test-ctx".to_string()),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = LoginUserRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn encoded_size_matches_output() {
+        let req = LoginUserRequest {
+            username: WireName::new("admin").unwrap(),
+            password: "p".to_string(),
+            version: Some("v1".to_string()),
+            context: Some("ctx".to_string()),
+        };
+        assert_eq!(req.encoded_size(), req.to_bytes().len());
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = LoginUserRequest {
+            username: WireName::new("u").unwrap(),
+            password: "p".to_string(),
+            version: Some("v".to_string()),
+            context: Some("c".to_string()),
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                LoginUserRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn wire_layout_no_optionals() {
+        let req = LoginUserRequest {
+            username: WireName::new("u").unwrap(),
+            password: "p".to_string(),
+            version: None,
+            context: None,
+        };
+        let bytes = req.to_bytes();
+        // username: [1, b'u'] + password: [1, b'p'] + version_len: [0,0,0,0] + context_len: [0,0,0,0]
+        let expected: &[u8] = &[1, b'u', 1, b'p', 0, 0, 0, 0, 0, 0, 0, 0];
+        assert_eq!(&bytes[..], expected);
+    }
+}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/users/logout_user.rs
similarity index 69%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/users/logout_user.rs
index 7b062a665..54000aa65 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/users/logout_user.rs
@@ -15,17 +15,15 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
-
 use crate::WireError;
 use crate::codec::{WireDecode, WireEncode};
 use bytes::BytesMut;
 
-/// Marker type for commands that return an empty response payload.
+/// `LogoutUser` request. Wire format: empty.
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct LogoutUserRequest;
 
-impl WireEncode for EmptyResponse {
+impl WireEncode for LogoutUserRequest {
     fn encoded_size(&self) -> usize {
         0
     }
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
     fn encode(&self, _buf: &mut BytesMut) {}
 }
 
-impl WireDecode for EmptyResponse {
+impl WireDecode for LogoutUserRequest {
     fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
         Ok((Self, 0))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip() {
+        let req = LogoutUserRequest;
+        let bytes = req.to_bytes();
+        assert!(bytes.is_empty());
+        let (decoded, consumed) = LogoutUserRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, 0);
+        assert_eq!(decoded, req);
+    }
+}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/users/mod.rs
similarity index 58%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/users/mod.rs
index 7b062a665..d83634415 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/users/mod.rs
@@ -15,26 +15,22 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+pub mod change_password;
+pub mod create_user;
+pub mod delete_user;
+pub mod get_user;
+pub mod get_users;
+pub mod login_user;
+pub mod logout_user;
+pub mod update_permissions;
+pub mod update_user;
 
-use crate::WireError;
-use crate::codec::{WireDecode, WireEncode};
-use bytes::BytesMut;
-
-/// Marker type for commands that return an empty response payload.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
-
-impl WireEncode for EmptyResponse {
-    fn encoded_size(&self) -> usize {
-        0
-    }
-
-    fn encode(&self, _buf: &mut BytesMut) {}
-}
-
-impl WireDecode for EmptyResponse {
-    fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
-        Ok((Self, 0))
-    }
-}
+pub use change_password::ChangePasswordRequest;
+pub use create_user::CreateUserRequest;
+pub use delete_user::DeleteUserRequest;
+pub use get_user::GetUserRequest;
+pub use get_users::GetUsersRequest;
+pub use login_user::LoginUserRequest;
+pub use logout_user::LogoutUserRequest;
+pub use update_permissions::UpdatePermissionsRequest;
+pub use update_user::UpdateUserRequest;
diff --git a/core/binary_protocol/src/requests/users/update_permissions.rs b/core/binary_protocol/src/requests/users/update_permissions.rs
new file mode 100644
index 000000000..6b5bcbbc4
--- /dev/null
+++ b/core/binary_protocol/src/requests/users/update_permissions.rs
@@ -0,0 +1,160 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le};
+use crate::primitives::permissions::WirePermissions;
+use bytes::{BufMut, BytesMut};
+
+/// `UpdatePermissions` request.
+///
+/// Wire format:
+/// `[user_id:WireIdentifier][has_permissions:u8][permissions_len:u32_le?][permissions:M?]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct UpdatePermissionsRequest {
+    pub user_id: WireIdentifier,
+    pub permissions: Option<WirePermissions>,
+}
+
+impl WireEncode for UpdatePermissionsRequest {
+    fn encoded_size(&self) -> usize {
+        self.user_id.encoded_size()
+            + 1 // has_permissions
+            + 4 // permissions_len (always present)
+            + self.permissions.as_ref().map_or(0, WireEncode::encoded_size)
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.user_id.encode(buf);
+        if let Some(perms) = &self.permissions {
+            buf.put_u8(1);
+            let perm_bytes = perms.to_bytes();
+            #[allow(clippy::cast_possible_truncation)]
+            buf.put_u32_le(perm_bytes.len() as u32);
+            buf.put_slice(&perm_bytes);
+        } else {
+            buf.put_u8(0);
+            buf.put_u32_le(0);
+        }
+    }
+}
+
+impl WireDecode for UpdatePermissionsRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (user_id, mut pos) = WireIdentifier::decode(buf)?;
+
+        let has_permissions = read_u8(buf, pos)?;
+        pos += 1;
+
+        let perm_len = read_u32_le(buf, pos)? as usize;
+        pos += 4;
+
+        let permissions = if has_permissions == 1 && perm_len > 0 {
+            let (perms, consumed) = WirePermissions::decode(&buf[pos..])?;
+            if consumed != perm_len {
+                return Err(WireError::Validation(format!(
+                    "permissions length mismatch: header says {perm_len}, decoded {consumed}"
+                )));
+            }
+            pos += consumed;
+            Some(perms)
+        } else {
+            None
+        };
+
+        Ok((
+            Self {
+                user_id,
+                permissions,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::primitives::permissions::{WireGlobalPermissions, WirePermissions};
+
+    fn sample_permissions() -> WirePermissions {
+        WirePermissions {
+            global: WireGlobalPermissions {
+                manage_servers: true,
+                read_servers: true,
+                manage_users: true,
+                read_users: true,
+                manage_streams: false,
+                read_streams: false,
+                manage_topics: false,
+                read_topics: false,
+                poll_messages: true,
+                send_messages: true,
+            },
+            streams: vec![],
+        }
+    }
+
+    #[test]
+    fn roundtrip_with_permissions() {
+        let req = UpdatePermissionsRequest {
+            user_id: WireIdentifier::numeric(1),
+            permissions: Some(sample_permissions()),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = UpdatePermissionsRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_without_permissions() {
+        let req = UpdatePermissionsRequest {
+            user_id: WireIdentifier::named("admin").unwrap(),
+            permissions: None,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = UpdatePermissionsRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn encoded_size_matches_output() {
+        let req = UpdatePermissionsRequest {
+            user_id: WireIdentifier::numeric(42),
+            permissions: Some(sample_permissions()),
+        };
+        assert_eq!(req.encoded_size(), req.to_bytes().len());
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = UpdatePermissionsRequest {
+            user_id: WireIdentifier::numeric(1),
+            permissions: Some(sample_permissions()),
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                UpdatePermissionsRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/requests/users/update_user.rs b/core/binary_protocol/src/requests/users/update_user.rs
new file mode 100644
index 000000000..4e2d70aa1
--- /dev/null
+++ b/core/binary_protocol/src/requests/users/update_user.rs
@@ -0,0 +1,184 @@
+// 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.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8};
+use crate::primitives::identifier::WireName;
+use bytes::{BufMut, BytesMut};
+
+/// `UpdateUser` request.
+///
+/// Wire format:
+/// `[user_id:WireIdentifier][has_username:u8][username_len:u8?][username:N?]
+///  [has_status:u8][status:u8?]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct UpdateUserRequest {
+    pub user_id: WireIdentifier,
+    pub username: Option<WireName>,
+    pub status: Option<u8>,
+}
+
+impl WireEncode for UpdateUserRequest {
+    fn encoded_size(&self) -> usize {
+        self.user_id.encoded_size()
+            + 1 // has_username
+            + self.username.as_ref().map_or(0, WireEncode::encoded_size)
+            + 1 // has_status
+            + self.status.map_or(0, |_| 1)
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.user_id.encode(buf);
+        match &self.username {
+            Some(name) => {
+                buf.put_u8(1);
+                name.encode(buf);
+            }
+            None => {
+                buf.put_u8(0);
+            }
+        }
+        match self.status {
+            Some(s) => {
+                buf.put_u8(1);
+                buf.put_u8(s);
+            }
+            None => {
+                buf.put_u8(0);
+            }
+        }
+    }
+}
+
+impl WireDecode for UpdateUserRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (user_id, mut pos) = WireIdentifier::decode(buf)?;
+
+        let has_username = read_u8(buf, pos)?;
+        pos += 1;
+        let username = if has_username == 1 {
+            let (name, consumed) = WireName::decode(&buf[pos..])?;
+            pos += consumed;
+            Some(name)
+        } else {
+            None
+        };
+
+        let has_status = read_u8(buf, pos)?;
+        pos += 1;
+        let status = if has_status == 1 {
+            let s = read_u8(buf, pos)?;
+            pos += 1;
+            Some(s)
+        } else {
+            None
+        };
+
+        Ok((
+            Self {
+                user_id,
+                username,
+                status,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip_both_present() {
+        let req = UpdateUserRequest {
+            user_id: WireIdentifier::numeric(1),
+            username: Some(WireName::new("new-name").unwrap()),
+            status: Some(2),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = UpdateUserRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_both_none() {
+        let req = UpdateUserRequest {
+            user_id: WireIdentifier::numeric(5),
+            username: None,
+            status: None,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = UpdateUserRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_username_only() {
+        let req = UpdateUserRequest {
+            user_id: WireIdentifier::named("admin").unwrap(),
+            username: Some(WireName::new("super-admin").unwrap()),
+            status: None,
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = UpdateUserRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn roundtrip_status_only() {
+        let req = UpdateUserRequest {
+            user_id: WireIdentifier::numeric(10),
+            username: None,
+            status: Some(0),
+        };
+        let bytes = req.to_bytes();
+        let (decoded, consumed) = UpdateUserRequest::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, req);
+    }
+
+    #[test]
+    fn encoded_size_matches_output() {
+        let req = UpdateUserRequest {
+            user_id: WireIdentifier::numeric(1),
+            username: Some(WireName::new("test").unwrap()),
+            status: Some(1),
+        };
+        assert_eq!(req.encoded_size(), req.to_bytes().len());
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let req = UpdateUserRequest {
+            user_id: WireIdentifier::numeric(1),
+            username: Some(WireName::new("name").unwrap()),
+            status: Some(1),
+        };
+        let bytes = req.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                UpdateUserRequest::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/responses/clients/client_response.rs b/core/binary_protocol/src/responses/clients/client_response.rs
new file mode 100644
index 000000000..73e871402
--- /dev/null
+++ b/core/binary_protocol/src/responses/clients/client_response.rs
@@ -0,0 +1,211 @@
+// 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.
+
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode, read_str, read_u8, read_u32_le};
+use bytes::{BufMut, BytesMut};
+
+/// Consumer group membership entry.
+///
+/// Wire format (12 bytes):
+/// ```text
+/// [stream_id:4][topic_id:4][group_id:4]
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ConsumerGroupInfoResponse {
+    pub stream_id: u32,
+    pub topic_id: u32,
+    pub group_id: u32,
+}
+
+impl ConsumerGroupInfoResponse {
+    const SIZE: usize = 12;
+}
+
+impl WireEncode for ConsumerGroupInfoResponse {
+    fn encoded_size(&self) -> usize {
+        Self::SIZE
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        buf.put_u32_le(self.stream_id);
+        buf.put_u32_le(self.topic_id);
+        buf.put_u32_le(self.group_id);
+    }
+}
+
+impl WireDecode for ConsumerGroupInfoResponse {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let stream_id = read_u32_le(buf, 0)?;
+        let topic_id = read_u32_le(buf, 4)?;
+        let group_id = read_u32_le(buf, 8)?;
+
+        Ok((
+            Self {
+                stream_id,
+                topic_id,
+                group_id,
+            },
+            Self::SIZE,
+        ))
+    }
+}
+
+/// Client header on the wire. Used in both single-client and multi-client responses.
+///
+/// Wire format:
+/// ```text
+/// [client_id:4][user_id:4][transport:1][address_len:4][address:N][consumer_groups_count:4]
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ClientResponse {
+    pub client_id: u32,
+    pub user_id: u32,
+    pub transport: u8,
+    pub address: String,
+    pub consumer_groups_count: u32,
+}
+
+impl ClientResponse {
+    const FIXED_SIZE: usize = 4 + 4 + 1 + 4 + 4; // 17
+}
+
+impl WireEncode for ClientResponse {
+    fn encoded_size(&self) -> usize {
+        Self::FIXED_SIZE + self.address.len()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        buf.put_u32_le(self.client_id);
+        buf.put_u32_le(self.user_id);
+        buf.put_u8(self.transport);
+        #[allow(clippy::cast_possible_truncation)]
+        buf.put_u32_le(self.address.len() as u32);
+        buf.put_slice(self.address.as_bytes());
+        buf.put_u32_le(self.consumer_groups_count);
+    }
+}
+
+impl WireDecode for ClientResponse {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let client_id = read_u32_le(buf, 0)?;
+        let user_id = read_u32_le(buf, 4)?;
+        let transport = read_u8(buf, 8)?;
+        let address_len = read_u32_le(buf, 9)? as usize;
+        let address = read_str(buf, 13, address_len)?;
+        let consumer_groups_count = read_u32_le(buf, 13 + address_len)?;
+        let consumed = 17 + address_len;
+
+        Ok((
+            Self {
+                client_id,
+                user_id,
+                transport,
+                address,
+                consumer_groups_count,
+            },
+            consumed,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn sample_client() -> ClientResponse {
+        ClientResponse {
+            client_id: 1,
+            user_id: 10,
+            transport: 1,
+            address: "127.0.0.1:8080".to_string(),
+            consumer_groups_count: 2,
+        }
+    }
+
+    #[test]
+    fn consumer_group_info_roundtrip() {
+        let info = ConsumerGroupInfoResponse {
+            stream_id: 1,
+            topic_id: 2,
+            group_id: 3,
+        };
+        let bytes = info.to_bytes();
+        assert_eq!(bytes.len(), 12);
+        let (decoded, consumed) = ConsumerGroupInfoResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, 12);
+        assert_eq!(decoded, info);
+    }
+
+    #[test]
+    fn client_roundtrip() {
+        let client = sample_client();
+        let bytes = client.to_bytes();
+        assert_eq!(bytes.len(), ClientResponse::FIXED_SIZE + 14);
+        let (decoded, consumed) = ClientResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, client);
+    }
+
+    #[test]
+    fn client_truncated_returns_error() {
+        let client = sample_client();
+        let bytes = client.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                ClientResponse::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn client_empty_address() {
+        let client = ClientResponse {
+            address: String::new(),
+            ..sample_client()
+        };
+        let bytes = client.to_bytes();
+        let (decoded, consumed) = ClientResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, client);
+    }
+
+    #[test]
+    fn multiple_clients_sequential() {
+        let c1 = ClientResponse {
+            client_id: 1,
+            address: "a".to_string(),
+            ..sample_client()
+        };
+        let c2 = ClientResponse {
+            client_id: 2,
+            address: "bb".to_string(),
+            ..sample_client()
+        };
+        let mut buf = BytesMut::new();
+        c1.encode(&mut buf);
+        c2.encode(&mut buf);
+        let bytes = buf.freeze();
+
+        let (d1, pos1) = ClientResponse::decode(&bytes).unwrap();
+        let (d2, pos2) = ClientResponse::decode(&bytes[pos1..]).unwrap();
+        assert_eq!(d1, c1);
+        assert_eq!(d2, c2);
+        assert_eq!(pos1 + pos2, bytes.len());
+    }
+}
diff --git a/core/binary_protocol/src/responses/clients/get_client.rs b/core/binary_protocol/src/responses/clients/get_client.rs
new file mode 100644
index 000000000..f2fe4c2f0
--- /dev/null
+++ b/core/binary_protocol/src/responses/clients/get_client.rs
@@ -0,0 +1,143 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode};
+use crate::responses::clients::client_response::{ClientResponse, ConsumerGroupInfoResponse};
+use bytes::BytesMut;
+
+/// `GetClient` response: client header followed by consumer group entries.
+///
+/// Wire format:
+/// ```text
+/// [ClientResponse][ConsumerGroupInfoResponse]*
+/// ```
+///
+/// The number of consumer group entries equals `client.consumer_groups_count`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ClientDetailsResponse {
+    pub client: ClientResponse,
+    pub consumer_groups: Vec<ConsumerGroupInfoResponse>,
+}
+
+impl WireEncode for ClientDetailsResponse {
+    fn encoded_size(&self) -> usize {
+        self.client.encoded_size()
+            + self
+                .consumer_groups
+                .iter()
+                .map(WireEncode::encoded_size)
+                .sum::<usize>()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.client.encode(buf);
+        for group in &self.consumer_groups {
+            group.encode(buf);
+        }
+    }
+}
+
+impl WireDecode for ClientDetailsResponse {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (client, mut pos) = ClientResponse::decode(buf)?;
+        let count = client.consumer_groups_count as usize;
+        let mut consumer_groups = Vec::with_capacity(count);
+        for _ in 0..count {
+            let (group, consumed) = ConsumerGroupInfoResponse::decode(&buf[pos..])?;
+            pos += consumed;
+            consumer_groups.push(group);
+        }
+
+        Ok((
+            Self {
+                client,
+                consumer_groups,
+            },
+            pos,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn sample_client(groups_count: u32) -> ClientResponse {
+        ClientResponse {
+            client_id: 1,
+            user_id: 10,
+            transport: 1,
+            address: "127.0.0.1:8080".to_string(),
+            consumer_groups_count: groups_count,
+        }
+    }
+
+    #[test]
+    fn roundtrip_no_groups() {
+        let resp = ClientDetailsResponse {
+            client: sample_client(0),
+            consumer_groups: vec![],
+        };
+        let bytes = resp.to_bytes();
+        let (decoded, consumed) = ClientDetailsResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, resp);
+    }
+
+    #[test]
+    fn roundtrip_with_groups() {
+        let resp = ClientDetailsResponse {
+            client: sample_client(2),
+            consumer_groups: vec![
+                ConsumerGroupInfoResponse {
+                    stream_id: 1,
+                    topic_id: 2,
+                    group_id: 3,
+                },
+                ConsumerGroupInfoResponse {
+                    stream_id: 4,
+                    topic_id: 5,
+                    group_id: 6,
+                },
+            ],
+        };
+        let bytes = resp.to_bytes();
+        let (decoded, consumed) = ClientDetailsResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, resp);
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let resp = ClientDetailsResponse {
+            client: sample_client(1),
+            consumer_groups: vec![ConsumerGroupInfoResponse {
+                stream_id: 1,
+                topic_id: 2,
+                group_id: 3,
+            }],
+        };
+        let bytes = resp.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                ClientDetailsResponse::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/responses/clients/get_clients.rs b/core/binary_protocol/src/responses/clients/get_clients.rs
new file mode 100644
index 000000000..7b462fdb3
--- /dev/null
+++ b/core/binary_protocol/src/responses/clients/get_clients.rs
@@ -0,0 +1,98 @@
+// 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.
+
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode};
+use crate::responses::clients::client_response::ClientResponse;
+use bytes::BytesMut;
+
+/// `GetClients` response: sequential client headers.
+///
+/// Wire format:
+/// ```text
+/// [ClientResponse]*
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GetClientsResponse {
+    pub clients: Vec<ClientResponse>,
+}
+
+impl WireEncode for GetClientsResponse {
+    fn encoded_size(&self) -> usize {
+        self.clients.iter().map(WireEncode::encoded_size).sum()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        for client in &self.clients {
+            client.encode(buf);
+        }
+    }
+}
+
+impl WireDecode for GetClientsResponse {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let mut clients = Vec::new();
+        let mut pos = 0;
+        while pos < buf.len() {
+            let (client, consumed) = ClientResponse::decode(&buf[pos..])?;
+            pos += consumed;
+            clients.push(client);
+        }
+        Ok((Self { clients }, pos))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip_empty() {
+        let resp = GetClientsResponse { clients: vec![] };
+        let bytes = resp.to_bytes();
+        assert!(bytes.is_empty());
+        let (decoded, consumed) = GetClientsResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, 0);
+        assert_eq!(decoded, resp);
+    }
+
+    #[test]
+    fn roundtrip_multiple() {
+        let resp = GetClientsResponse {
+            clients: vec![
+                ClientResponse {
+                    client_id: 1,
+                    user_id: 10,
+                    transport: 1,
+                    address: "127.0.0.1:5000".to_string(),
+                    consumer_groups_count: 0,
+                },
+                ClientResponse {
+                    client_id: 2,
+                    user_id: 20,
+                    transport: 2,
+                    address: "10.0.0.1:6000".to_string(),
+                    consumer_groups_count: 3,
+                },
+            ],
+        };
+        let bytes = resp.to_bytes();
+        let (decoded, consumed) = GetClientsResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, resp);
+    }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/clients/mod.rs
similarity index 78%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/clients/mod.rs
index e7b687f1f..d76aa51a5 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/clients/mod.rs
@@ -15,4 +15,10 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+pub mod client_response;
+pub mod get_client;
+pub mod get_clients;
+
+pub use client_response::{ClientResponse, ConsumerGroupInfoResponse};
+pub use get_client::ClientDetailsResponse;
+pub use get_clients::GetClientsResponse;
diff --git a/core/binary_protocol/src/responses/streams/stream_response.rs b/core/binary_protocol/src/responses/consumer_groups/consumer_group_response.rs
similarity index 51%
copy from core/binary_protocol/src/responses/streams/stream_response.rs
copy to core/binary_protocol/src/responses/consumer_groups/consumer_group_response.rs
index b6fbe02e9..46d047b95 100644
--- a/core/binary_protocol/src/responses/streams/stream_response.rs
+++ b/core/binary_protocol/src/responses/consumer_groups/consumer_group_response.rs
@@ -16,62 +16,54 @@
 // under the License.
 
 use crate::WireError;
-use crate::codec::{WireDecode, WireEncode, read_u32_le, read_u64_le};
-use crate::identifier::WireName;
+use crate::codec::{WireDecode, WireEncode, read_u32_le};
+use crate::primitives::identifier::WireName;
 use bytes::{BufMut, BytesMut};
 
-/// Stream header on the wire. Used in both single-stream and multi-stream responses.
+/// Consumer group header on the wire.
 ///
-/// Wire format (33 + `name_len` bytes):
+/// Wire format (12 + 1 + `name_len` bytes):
 /// ```text
-/// [id:4][created_at:8][topics_count:4][size_bytes:8][messages_count:8][name_len:1][name:N]
+/// [id:4][partitions_count:4][members_count:4][name_len:1][name:N]
 /// ```
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct StreamResponse {
+pub struct ConsumerGroupResponse {
     pub id: u32,
-    pub created_at: u64,
-    pub topics_count: u32,
-    pub size_bytes: u64,
-    pub messages_count: u64,
+    pub partitions_count: u32,
+    pub members_count: u32,
     pub name: WireName,
 }
 
-impl StreamResponse {
-    const FIXED_SIZE: usize = 4 + 8 + 4 + 8 + 8 + 1; // 33
+impl ConsumerGroupResponse {
+    const FIXED_SIZE: usize = 4 + 4 + 4; // 12 (name length prefix is part of WireName)
 }
 
-impl WireEncode for StreamResponse {
+impl WireEncode for ConsumerGroupResponse {
     fn encoded_size(&self) -> usize {
-        Self::FIXED_SIZE + self.name.len()
+        Self::FIXED_SIZE + self.name.encoded_size()
     }
 
     fn encode(&self, buf: &mut BytesMut) {
         buf.put_u32_le(self.id);
-        buf.put_u64_le(self.created_at);
-        buf.put_u32_le(self.topics_count);
-        buf.put_u64_le(self.size_bytes);
-        buf.put_u64_le(self.messages_count);
+        buf.put_u32_le(self.partitions_count);
+        buf.put_u32_le(self.members_count);
         self.name.encode(buf);
     }
 }
 
-impl WireDecode for StreamResponse {
+impl WireDecode for ConsumerGroupResponse {
     fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
         let id = read_u32_le(buf, 0)?;
-        let created_at = read_u64_le(buf, 4)?;
-        let topics_count = read_u32_le(buf, 12)?;
-        let size_bytes = read_u64_le(buf, 16)?;
-        let messages_count = read_u64_le(buf, 24)?;
-        let (name, name_consumed) = WireName::decode(&buf[32..])?;
-        let consumed = 32 + name_consumed;
+        let partitions_count = read_u32_le(buf, 4)?;
+        let members_count = read_u32_le(buf, 8)?;
+        let (name, name_consumed) = WireName::decode(&buf[12..])?;
+        let consumed = 12 + name_consumed;
 
         Ok((
             Self {
                 id,
-                created_at,
-                topics_count,
-                size_bytes,
-                messages_count,
+                partitions_count,
+                members_count,
                 name,
             },
             consumed,
@@ -83,14 +75,12 @@ impl WireDecode for StreamResponse {
 mod tests {
     use super::*;
 
-    fn sample() -> StreamResponse {
-        StreamResponse {
+    fn sample() -> ConsumerGroupResponse {
+        ConsumerGroupResponse {
             id: 1,
-            created_at: 1_710_000_000_000,
-            topics_count: 3,
-            size_bytes: 1024,
-            messages_count: 100,
-            name: WireName::new("test-stream").unwrap(),
+            partitions_count: 4,
+            members_count: 2,
+            name: WireName::new("my-group").unwrap(),
         }
     }
 
@@ -98,8 +88,8 @@ mod tests {
     fn roundtrip() {
         let resp = sample();
         let bytes = resp.to_bytes();
-        assert_eq!(bytes.len(), StreamResponse::FIXED_SIZE + 11);
-        let (decoded, consumed) = StreamResponse::decode(&bytes).unwrap();
+        assert_eq!(bytes.len(), 12 + 1 + 8);
+        let (decoded, consumed) = ConsumerGroupResponse::decode(&bytes).unwrap();
         assert_eq!(consumed, bytes.len());
         assert_eq!(decoded, resp);
     }
@@ -110,33 +100,33 @@ mod tests {
         let bytes = resp.to_bytes();
         for i in 0..bytes.len() {
             assert!(
-                StreamResponse::decode(&bytes[..i]).is_err(),
+                ConsumerGroupResponse::decode(&bytes[..i]).is_err(),
                 "expected error for truncation at byte {i}"
             );
         }
     }
 
     #[test]
-    fn multiple_streams_sequential() {
-        let s1 = StreamResponse {
+    fn multiple_sequential() {
+        let g1 = ConsumerGroupResponse {
             id: 1,
             name: WireName::new("a").unwrap(),
             ..sample()
         };
-        let s2 = StreamResponse {
+        let g2 = ConsumerGroupResponse {
             id: 2,
             name: WireName::new("bb").unwrap(),
             ..sample()
         };
         let mut buf = BytesMut::new();
-        s1.encode(&mut buf);
-        s2.encode(&mut buf);
+        g1.encode(&mut buf);
+        g2.encode(&mut buf);
         let bytes = buf.freeze();
 
-        let (d1, pos1) = StreamResponse::decode(&bytes).unwrap();
-        let (d2, pos2) = StreamResponse::decode(&bytes[pos1..]).unwrap();
-        assert_eq!(d1, s1);
-        assert_eq!(d2, s2);
+        let (d1, pos1) = ConsumerGroupResponse::decode(&bytes).unwrap();
+        let (d2, pos2) = ConsumerGroupResponse::decode(&bytes[pos1..]).unwrap();
+        assert_eq!(d1, g1);
+        assert_eq!(d2, g2);
         assert_eq!(pos1 + pos2, bytes.len());
     }
 }
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/consumer_groups/create_consumer_group.rs
similarity index 88%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/consumer_groups/create_consumer_group.rs
index e7b687f1f..9d635e0f0 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/consumer_groups/create_consumer_group.rs
@@ -15,4 +15,5 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+/// `CreateConsumerGroup` response is empty.
+pub type CreateConsumerGroupResponse = super::EmptyResponse;
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/consumer_groups/delete_consumer_group.rs
similarity index 88%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/consumer_groups/delete_consumer_group.rs
index e7b687f1f..ff59f0b36 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/consumer_groups/delete_consumer_group.rs
@@ -15,4 +15,5 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+/// `DeleteConsumerGroup` response is empty.
+pub type DeleteConsumerGroupResponse = super::EmptyResponse;
diff --git a/core/binary_protocol/src/responses/consumer_groups/get_consumer_group.rs b/core/binary_protocol/src/responses/consumer_groups/get_consumer_group.rs
new file mode 100644
index 000000000..39ecab2d1
--- /dev/null
+++ b/core/binary_protocol/src/responses/consumer_groups/get_consumer_group.rs
@@ -0,0 +1,210 @@
+// 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.
+
+use super::consumer_group_response::ConsumerGroupResponse;
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode, read_u32_le};
+use bytes::{BufMut, BytesMut};
+
+/// A consumer group member with its assigned partitions.
+///
+/// Wire format:
+/// ```text
+/// [id:4][partitions_count:4][partition_id:4]*
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ConsumerGroupMemberResponse {
+    pub id: u32,
+    pub partitions_count: u32,
+    pub partitions: Vec<u32>,
+}
+
+impl WireEncode for ConsumerGroupMemberResponse {
+    fn encoded_size(&self) -> usize {
+        4 + 4 + self.partitions.len() * 4
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        buf.put_u32_le(self.id);
+        buf.put_u32_le(self.partitions_count);
+        for &partition_id in &self.partitions {
+            buf.put_u32_le(partition_id);
+        }
+    }
+}
+
+impl WireDecode for ConsumerGroupMemberResponse {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let id = read_u32_le(buf, 0)?;
+        let partitions_count = read_u32_le(buf, 4)?;
+        let mut partitions = Vec::with_capacity(partitions_count as usize);
+        let mut offset = 8;
+        for _ in 0..partitions_count {
+            let partition_id = read_u32_le(buf, offset)?;
+            partitions.push(partition_id);
+            offset += 4;
+        }
+        Ok((
+            Self {
+                id,
+                partitions_count,
+                partitions,
+            },
+            offset,
+        ))
+    }
+}
+
+/// `GetConsumerGroup` response: group header followed by member details.
+///
+/// Wire format:
+/// ```text
+/// [ConsumerGroupResponse][ConsumerGroupMemberResponse]*
+/// ```
+///
+/// The number of members must match `group.members_count`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ConsumerGroupDetailsResponse {
+    pub group: ConsumerGroupResponse,
+    pub members: Vec<ConsumerGroupMemberResponse>,
+}
+
+impl WireEncode for ConsumerGroupDetailsResponse {
+    fn encoded_size(&self) -> usize {
+        self.group.encoded_size()
+            + self
+                .members
+                .iter()
+                .map(WireEncode::encoded_size)
+                .sum::<usize>()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.group.encode(buf);
+        for member in &self.members {
+            member.encode(buf);
+        }
+    }
+}
+
+impl WireDecode for ConsumerGroupDetailsResponse {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (group, mut pos) = ConsumerGroupResponse::decode(buf)?;
+        let mut members = Vec::new();
+        for _ in 0..group.members_count {
+            let (member, consumed) = ConsumerGroupMemberResponse::decode(&buf[pos..])?;
+            pos += consumed;
+            members.push(member);
+        }
+        Ok((Self { group, members }, pos))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::WireName;
+
+    fn sample_group(members_count: u32) -> ConsumerGroupResponse {
+        ConsumerGroupResponse {
+            id: 1,
+            partitions_count: 6,
+            members_count,
+            name: WireName::new("my-group").unwrap(),
+        }
+    }
+
+    fn sample_member(id: u32, partitions: &[u32]) -> ConsumerGroupMemberResponse {
+        ConsumerGroupMemberResponse {
+            id,
+            #[allow(clippy::cast_possible_truncation)]
+            partitions_count: partitions.len() as u32,
+            partitions: partitions.to_vec(),
+        }
+    }
+
+    #[test]
+    fn member_roundtrip() {
+        let m = sample_member(1, &[0, 1, 2]);
+        let bytes = m.to_bytes();
+        assert_eq!(bytes.len(), 4 + 4 + 3 * 4);
+        let (decoded, consumed) = ConsumerGroupMemberResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, m);
+    }
+
+    #[test]
+    fn member_no_partitions_roundtrip() {
+        let m = sample_member(5, &[]);
+        let bytes = m.to_bytes();
+        assert_eq!(bytes.len(), 8);
+        let (decoded, consumed) = ConsumerGroupMemberResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, 8);
+        assert_eq!(decoded, m);
+    }
+
+    #[test]
+    fn member_truncated_returns_error() {
+        let m = sample_member(1, &[0, 1]);
+        let bytes = m.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                ConsumerGroupMemberResponse::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn details_roundtrip_with_members() {
+        let resp = ConsumerGroupDetailsResponse {
+            group: sample_group(2),
+            members: vec![sample_member(1, &[0, 1, 2]), sample_member(2, &[3, 4, 5])],
+        };
+        let bytes = resp.to_bytes();
+        let (decoded, consumed) = ConsumerGroupDetailsResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, resp);
+    }
+
+    #[test]
+    fn details_roundtrip_no_members() {
+        let resp = ConsumerGroupDetailsResponse {
+            group: sample_group(0),
+            members: vec![],
+        };
+        let bytes = resp.to_bytes();
+        let (decoded, consumed) = ConsumerGroupDetailsResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, resp);
+    }
+
+    #[test]
+    fn details_truncated_returns_error() {
+        let resp = ConsumerGroupDetailsResponse {
+            group: sample_group(1),
+            members: vec![sample_member(1, &[0])],
+        };
+        let bytes = resp.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                ConsumerGroupDetailsResponse::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/responses/consumer_groups/get_consumer_groups.rs b/core/binary_protocol/src/responses/consumer_groups/get_consumer_groups.rs
new file mode 100644
index 000000000..bc4324751
--- /dev/null
+++ b/core/binary_protocol/src/responses/consumer_groups/get_consumer_groups.rs
@@ -0,0 +1,109 @@
+// 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.
+
+use super::consumer_group_response::ConsumerGroupResponse;
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode};
+use bytes::BytesMut;
+
+/// `GetConsumerGroups` response: sequential consumer group headers.
+///
+/// Wire format:
+/// ```text
+/// [ConsumerGroupResponse]*
+/// ```
+///
+/// Empty payload means zero groups.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GetConsumerGroupsResponse {
+    pub groups: Vec<ConsumerGroupResponse>,
+}
+
+impl WireEncode for GetConsumerGroupsResponse {
+    fn encoded_size(&self) -> usize {
+        self.groups.iter().map(WireEncode::encoded_size).sum()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        for group in &self.groups {
+            group.encode(buf);
+        }
+    }
+}
+
+impl WireDecode for GetConsumerGroupsResponse {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let mut groups = Vec::new();
+        let mut pos = 0;
+        while pos < buf.len() {
+            let (group, consumed) = ConsumerGroupResponse::decode(&buf[pos..])?;
+            pos += consumed;
+            groups.push(group);
+        }
+        Ok((Self { groups }, pos))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::WireName;
+
+    fn sample_group(id: u32, name: &str) -> ConsumerGroupResponse {
+        ConsumerGroupResponse {
+            id,
+            partitions_count: 4,
+            members_count: 2,
+            name: WireName::new(name).unwrap(),
+        }
+    }
+
+    #[test]
+    fn roundtrip_empty() {
+        let resp = GetConsumerGroupsResponse { groups: vec![] };
+        let bytes = resp.to_bytes();
+        assert!(bytes.is_empty());
+        let (decoded, consumed) = GetConsumerGroupsResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, 0);
+        assert_eq!(decoded, resp);
+    }
+
+    #[test]
+    fn roundtrip_multiple() {
+        let resp = GetConsumerGroupsResponse {
+            groups: vec![sample_group(1, "group-a"), sample_group(2, "group-b")],
+        };
+        let bytes = resp.to_bytes();
+        let (decoded, consumed) = GetConsumerGroupsResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, resp);
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let resp = GetConsumerGroupsResponse {
+            groups: vec![sample_group(1, "g")],
+        };
+        let bytes = resp.to_bytes();
+        for i in 1..bytes.len() {
+            assert!(
+                GetConsumerGroupsResponse::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/consumer_groups/join_consumer_group.rs
similarity index 88%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/consumer_groups/join_consumer_group.rs
index e7b687f1f..0a39201ce 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/consumer_groups/join_consumer_group.rs
@@ -15,4 +15,5 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+/// `JoinConsumerGroup` response is empty.
+pub type JoinConsumerGroupResponse = super::EmptyResponse;
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/consumer_groups/leave_consumer_group.rs
similarity index 88%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/consumer_groups/leave_consumer_group.rs
index e7b687f1f..7165c4a86 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/consumer_groups/leave_consumer_group.rs
@@ -15,4 +15,5 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+/// `LeaveConsumerGroup` response is empty.
+pub type LeaveConsumerGroupResponse = super::EmptyResponse;
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/responses/consumer_groups/mod.rs
similarity index 54%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/responses/consumer_groups/mod.rs
index 7b062a665..17c72a8a2 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/responses/consumer_groups/mod.rs
@@ -15,26 +15,19 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+pub mod consumer_group_response;
+mod create_consumer_group;
+mod delete_consumer_group;
+pub mod get_consumer_group;
+pub mod get_consumer_groups;
+mod join_consumer_group;
+mod leave_consumer_group;
 
-use crate::WireError;
-use crate::codec::{WireDecode, WireEncode};
-use bytes::BytesMut;
-
-/// Marker type for commands that return an empty response payload.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
-
-impl WireEncode for EmptyResponse {
-    fn encoded_size(&self) -> usize {
-        0
-    }
-
-    fn encode(&self, _buf: &mut BytesMut) {}
-}
-
-impl WireDecode for EmptyResponse {
-    fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
-        Ok((Self, 0))
-    }
-}
+pub use super::EmptyResponse;
+pub use consumer_group_response::ConsumerGroupResponse;
+pub use create_consumer_group::CreateConsumerGroupResponse;
+pub use delete_consumer_group::DeleteConsumerGroupResponse;
+pub use get_consumer_group::{ConsumerGroupDetailsResponse, ConsumerGroupMemberResponse};
+pub use get_consumer_groups::GetConsumerGroupsResponse;
+pub use join_consumer_group::JoinConsumerGroupResponse;
+pub use leave_consumer_group::LeaveConsumerGroupResponse;
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/consumer_offsets/delete_consumer_offset.rs
similarity index 88%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/consumer_offsets/delete_consumer_offset.rs
index e7b687f1f..85921ec0a 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/consumer_offsets/delete_consumer_offset.rs
@@ -15,4 +15,5 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+/// `DeleteConsumerOffset` response is empty.
+pub type DeleteConsumerOffsetResponse = super::EmptyResponse;
diff --git a/core/binary_protocol/src/responses/consumer_offsets/get_consumer_offset.rs b/core/binary_protocol/src/responses/consumer_offsets/get_consumer_offset.rs
new file mode 100644
index 000000000..ccd592a40
--- /dev/null
+++ b/core/binary_protocol/src/responses/consumer_offsets/get_consumer_offset.rs
@@ -0,0 +1,114 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode, read_u32_le, read_u64_le};
+use bytes::{BufMut, BytesMut};
+
+/// `GetConsumerOffset` response.
+///
+/// Wire format (20 bytes fixed):
+/// ```text
+/// [partition_id:4][current_offset:8][stored_offset:8]
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ConsumerOffsetResponse {
+    pub partition_id: u32,
+    pub current_offset: u64,
+    pub stored_offset: u64,
+}
+
+impl ConsumerOffsetResponse {
+    const FIXED_SIZE: usize = 4 + 8 + 8; // 20
+}
+
+impl WireEncode for ConsumerOffsetResponse {
+    fn encoded_size(&self) -> usize {
+        Self::FIXED_SIZE
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        buf.put_u32_le(self.partition_id);
+        buf.put_u64_le(self.current_offset);
+        buf.put_u64_le(self.stored_offset);
+    }
+}
+
+impl WireDecode for ConsumerOffsetResponse {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let partition_id = read_u32_le(buf, 0)?;
+        let current_offset = read_u64_le(buf, 4)?;
+        let stored_offset = read_u64_le(buf, 12)?;
+
+        Ok((
+            Self {
+                partition_id,
+                current_offset,
+                stored_offset,
+            },
+            Self::FIXED_SIZE,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn sample() -> ConsumerOffsetResponse {
+        ConsumerOffsetResponse {
+            partition_id: 1,
+            current_offset: 1000,
+            stored_offset: 500,
+        }
+    }
+
+    #[test]
+    fn roundtrip() {
+        let resp = sample();
+        let bytes = resp.to_bytes();
+        assert_eq!(bytes.len(), ConsumerOffsetResponse::FIXED_SIZE);
+        let (decoded, consumed) = ConsumerOffsetResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, ConsumerOffsetResponse::FIXED_SIZE);
+        assert_eq!(decoded, resp);
+    }
+
+    #[test]
+    fn truncated_returns_error() {
+        let resp = sample();
+        let bytes = resp.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                ConsumerOffsetResponse::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn boundary_values() {
+        let resp = ConsumerOffsetResponse {
+            partition_id: u32::MAX,
+            current_offset: u64::MAX,
+            stored_offset: 0,
+        };
+        let bytes = resp.to_bytes();
+        let (decoded, consumed) = ConsumerOffsetResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, ConsumerOffsetResponse::FIXED_SIZE);
+        assert_eq!(decoded, resp);
+    }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/consumer_offsets/mod.rs
similarity index 73%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/consumer_offsets/mod.rs
index e7b687f1f..915648c18 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/consumer_offsets/mod.rs
@@ -15,4 +15,11 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+mod delete_consumer_offset;
+pub mod get_consumer_offset;
+mod store_consumer_offset;
+
+pub use super::EmptyResponse;
+pub use delete_consumer_offset::DeleteConsumerOffsetResponse;
+pub use get_consumer_offset::ConsumerOffsetResponse;
+pub use store_consumer_offset::StoreConsumerOffsetResponse;
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/consumer_offsets/store_consumer_offset.rs
similarity index 88%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/consumer_offsets/store_consumer_offset.rs
index e7b687f1f..e9a41afca 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/consumer_offsets/store_consumer_offset.rs
@@ -15,4 +15,5 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+/// `StoreConsumerOffset` response is empty.
+pub type StoreConsumerOffsetResponse = super::EmptyResponse;
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/messages/mod.rs
similarity index 89%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/messages/mod.rs
index e7b687f1f..c272f3424 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/messages/mod.rs
@@ -15,4 +15,6 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+pub mod poll_messages;
+
+pub use poll_messages::{PollMessagesResponse, PollMessagesResponseHeader};
diff --git a/core/binary_protocol/src/responses/messages/poll_messages.rs b/core/binary_protocol/src/responses/messages/poll_messages.rs
new file mode 100644
index 000000000..cf31e4475
--- /dev/null
+++ b/core/binary_protocol/src/responses/messages/poll_messages.rs
@@ -0,0 +1,176 @@
+// 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.
+
+use crate::codec::{WireDecode, WireEncode, read_u32_le, read_u64_le};
+use crate::error::WireError;
+use crate::message_view::WireMessageIterator;
+use bytes::{BufMut, BytesMut};
+
+/// Size of the `PollMessages` response header: `partition_id(4) + current_offset(8) + count(4)`.
+const POLL_RESPONSE_HEADER_SIZE: usize = 16;
+
+/// The 16-byte metadata prefix of a `PollMessages` response.
+///
+/// Layout: `partition_id(4) + current_offset(8) + messages_count(4)`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PollMessagesResponseHeader {
+    pub partition_id: u32,
+    pub current_offset: u64,
+    pub messages_count: u32,
+}
+
+impl WireEncode for PollMessagesResponseHeader {
+    fn encoded_size(&self) -> usize {
+        POLL_RESPONSE_HEADER_SIZE
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        buf.put_u32_le(self.partition_id);
+        buf.put_u64_le(self.current_offset);
+        buf.put_u32_le(self.messages_count);
+    }
+}
+
+impl WireDecode for PollMessagesResponseHeader {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let partition_id = read_u32_le(buf, 0)?;
+        let current_offset = read_u64_le(buf, 4)?;
+        let messages_count = read_u32_le(buf, 12)?;
+        Ok((
+            Self {
+                partition_id,
+                current_offset,
+                messages_count,
+            },
+            POLL_RESPONSE_HEADER_SIZE,
+        ))
+    }
+}
+
+/// Borrowed `PollMessages` response. Does not own message data.
+///
+/// Does NOT implement `WireDecode` (trait returns owned data, we borrow).
+/// Use [`PollMessagesResponse::decode`] instead.
+pub struct PollMessagesResponse<'a> {
+    pub header: PollMessagesResponseHeader,
+    pub messages: WireMessageIterator<'a>,
+}
+
+impl<'a> PollMessagesResponse<'a> {
+    /// Decode from a response payload buffer. Borrows the buffer.
+    ///
+    /// Reads the 16-byte header then creates an iterator over the remaining
+    /// message frames. Messages are validated lazily during iteration.
+    ///
+    /// # Errors
+    /// Returns `WireError` if the buffer is too short for the response header.
+    pub fn decode(buf: &'a [u8]) -> Result<Self, WireError> {
+        let (header, _) = PollMessagesResponseHeader::decode(buf)?;
+        let messages =
+            WireMessageIterator::new(&buf[POLL_RESPONSE_HEADER_SIZE..], header.messages_count);
+        Ok(Self { header, messages })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::message_layout::{
+        MSG_ID_OFFSET, MSG_ORIGIN_TIMESTAMP_OFFSET, MSG_PAYLOAD_LEN_OFFSET,
+        WIRE_MESSAGE_HEADER_SIZE,
+    };
+
+    fn make_frame(payload: &[u8], id: u128, origin_ts: u64) -> Vec<u8> {
+        let total = WIRE_MESSAGE_HEADER_SIZE + payload.len();
+        let mut frame = vec![0u8; total];
+        frame[MSG_ID_OFFSET..MSG_ID_OFFSET + 16].copy_from_slice(&id.to_le_bytes());
+        frame[MSG_ORIGIN_TIMESTAMP_OFFSET..MSG_ORIGIN_TIMESTAMP_OFFSET + 8]
+            .copy_from_slice(&origin_ts.to_le_bytes());
+        #[allow(clippy::cast_possible_truncation)]
+        frame[MSG_PAYLOAD_LEN_OFFSET..MSG_PAYLOAD_LEN_OFFSET + 4]
+            .copy_from_slice(&(payload.len() as u32).to_le_bytes());
+        frame[WIRE_MESSAGE_HEADER_SIZE..].copy_from_slice(payload);
+        frame
+    }
+
+    #[test]
+    fn response_header_roundtrip() {
+        let header = PollMessagesResponseHeader {
+            partition_id: 7,
+            current_offset: 12345,
+            messages_count: 3,
+        };
+        let bytes = header.to_bytes();
+        assert_eq!(bytes.len(), POLL_RESPONSE_HEADER_SIZE);
+        let (decoded, consumed) = PollMessagesResponseHeader::decode(&bytes).unwrap();
+        assert_eq!(consumed, POLL_RESPONSE_HEADER_SIZE);
+        assert_eq!(decoded, header);
+    }
+
+    #[test]
+    fn response_header_truncation() {
+        let header = PollMessagesResponseHeader {
+            partition_id: 1,
+            current_offset: 0,
+            messages_count: 0,
+        };
+        let bytes = header.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                PollMessagesResponseHeader::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn response_decode_with_messages() {
+        let frame1 = make_frame(b"hello", 1, 100);
+        let frame2 = make_frame(b"world", 2, 200);
+
+        let mut buf = BytesMut::new();
+        buf.put_u32_le(42);
+        buf.put_u64_le(999);
+        buf.put_u32_le(2);
+        buf.put_slice(&frame1);
+        buf.put_slice(&frame2);
+
+        let resp = PollMessagesResponse::decode(&buf).unwrap();
+        assert_eq!(resp.header.partition_id, 42);
+        assert_eq!(resp.header.current_offset, 999);
+        assert_eq!(resp.header.messages_count, 2);
+
+        let views: Vec<_> = resp.messages.collect::<Result<Vec<_>, _>>().unwrap();
+        assert_eq!(views.len(), 2);
+        assert_eq!(views[0].id(), 1);
+        assert_eq!(views[0].payload(), b"hello");
+        assert_eq!(views[1].id(), 2);
+        assert_eq!(views[1].payload(), b"world");
+    }
+
+    #[test]
+    fn response_decode_zero_messages() {
+        let mut buf = BytesMut::new();
+        buf.put_u32_le(1);
+        buf.put_u64_le(0);
+        buf.put_u32_le(0);
+
+        let resp = PollMessagesResponse::decode(&buf).unwrap();
+        assert_eq!(resp.header.messages_count, 0);
+        assert_eq!(resp.messages.count(), 0);
+    }
+}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/responses/mod.rs
index 7b062a665..a009c15cb 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/responses/mod.rs
@@ -15,7 +15,15 @@
 // specific language governing permissions and limitations
 // under the License.
 
+pub mod clients;
+pub mod consumer_groups;
+pub mod consumer_offsets;
+pub mod messages;
+pub mod personal_access_tokens;
 pub mod streams;
+pub mod system;
+pub mod topics;
+pub mod users;
 
 use crate::WireError;
 use crate::codec::{WireDecode, WireEncode};
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/responses/personal_access_tokens/create_personal_access_token.rs
similarity index 54%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/responses/personal_access_tokens/create_personal_access_token.rs
index a8085f099..92ac752f9 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/responses/personal_access_tokens/create_personal_access_token.rs
@@ -16,35 +16,35 @@
 // under the License.
 
 use crate::WireError;
-use crate::WireIdentifier;
 use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
+use crate::primitives::identifier::WireName;
 use bytes::BytesMut;
 
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// Response for `CreatePersonalAccessToken`: the raw token string.
+///
+/// Wire format:
+/// ```text
+/// [token_len:1][token:N]
+/// ```
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
-    pub stream_id: WireIdentifier,
-    pub name: WireName,
+pub struct RawPersonalAccessTokenResponse {
+    pub token: WireName,
 }
 
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for RawPersonalAccessTokenResponse {
     fn encoded_size(&self) -> usize {
-        self.stream_id.encoded_size() + self.name.encoded_size()
+        self.token.encoded_size()
     }
 
     fn encode(&self, buf: &mut BytesMut) {
-        self.stream_id.encode(buf);
-        self.name.encode(buf);
+        self.token.encode(buf);
     }
 }
 
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for RawPersonalAccessTokenResponse {
     fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
-        let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
-        let (name, consumed) = WireName::decode(&buf[pos..])?;
-        pos += consumed;
-        Ok((Self { stream_id, name }, pos))
+        let (token, consumed) = WireName::decode(buf)?;
+        Ok((Self { token }, consumed))
     }
 }
 
@@ -54,26 +54,25 @@ mod tests {
 
     #[test]
     fn roundtrip() {
-        let req = UpdateStreamRequest {
-            stream_id: WireIdentifier::named("old-name").unwrap(),
-            name: WireName::new("new-name").unwrap(),
+        let resp = RawPersonalAccessTokenResponse {
+            token: WireName::new("raw-secret-token-value").unwrap(),
         };
-        let bytes = req.to_bytes();
-        let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+        let bytes = resp.to_bytes();
+        assert_eq!(bytes.len(), 1 + 22);
+        let (decoded, consumed) = RawPersonalAccessTokenResponse::decode(&bytes).unwrap();
         assert_eq!(consumed, bytes.len());
-        assert_eq!(decoded, req);
+        assert_eq!(decoded, resp);
     }
 
     #[test]
     fn truncated_returns_error() {
-        let req = UpdateStreamRequest {
-            stream_id: WireIdentifier::numeric(1),
-            name: WireName::new("test").unwrap(),
+        let resp = RawPersonalAccessTokenResponse {
+            token: WireName::new("tok").unwrap(),
         };
-        let bytes = req.to_bytes();
+        let bytes = resp.to_bytes();
         for i in 0..bytes.len() {
             assert!(
-                UpdateStreamRequest::decode(&bytes[..i]).is_err(),
+                RawPersonalAccessTokenResponse::decode(&bytes[..i]).is_err(),
                 "expected error for truncation at byte {i}"
             );
         }
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/personal_access_tokens/delete_personal_access_token.rs
similarity index 92%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/personal_access_tokens/delete_personal_access_token.rs
index e7b687f1f..c1cc301ea 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/personal_access_tokens/delete_personal_access_token.rs
@@ -15,4 +15,4 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+pub type DeletePersonalAccessTokenResponse = super::EmptyResponse;
diff --git a/core/binary_protocol/src/responses/personal_access_tokens/get_personal_access_tokens.rs b/core/binary_protocol/src/responses/personal_access_tokens/get_personal_access_tokens.rs
new file mode 100644
index 000000000..08ad66af6
--- /dev/null
+++ b/core/binary_protocol/src/responses/personal_access_tokens/get_personal_access_tokens.rs
@@ -0,0 +1,145 @@
+// 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.
+
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode, read_u64_le};
+use crate::primitives::identifier::WireName;
+use bytes::{BufMut, BytesMut};
+
+/// Single personal access token entry on the wire.
+///
+/// Wire format:
+/// ```text
+/// [name_len:1][name:N][expiry_at:8]
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PersonalAccessTokenResponse {
+    pub name: WireName,
+    pub expiry_at: u64,
+}
+
+impl WireEncode for PersonalAccessTokenResponse {
+    fn encoded_size(&self) -> usize {
+        self.name.encoded_size() + 8
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.name.encode(buf);
+        buf.put_u64_le(self.expiry_at);
+    }
+}
+
+impl WireDecode for PersonalAccessTokenResponse {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (name, name_consumed) = WireName::decode(buf)?;
+        let expiry_at = read_u64_le(buf, name_consumed)?;
+        let consumed = name_consumed + 8;
+
+        Ok((Self { name, expiry_at }, consumed))
+    }
+}
+
+/// `GetPersonalAccessTokens` response: sequential token entries.
+///
+/// Wire format:
+/// ```text
+/// [PersonalAccessTokenResponse]*
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GetPersonalAccessTokensResponse {
+    pub tokens: Vec<PersonalAccessTokenResponse>,
+}
+
+impl WireEncode for GetPersonalAccessTokensResponse {
+    fn encoded_size(&self) -> usize {
+        self.tokens.iter().map(WireEncode::encoded_size).sum()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        for token in &self.tokens {
+            token.encode(buf);
+        }
+    }
+}
+
+impl WireDecode for GetPersonalAccessTokensResponse {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let mut tokens = Vec::new();
+        let mut pos = 0;
+        while pos < buf.len() {
+            let (token, consumed) = PersonalAccessTokenResponse::decode(&buf[pos..])?;
+            pos += consumed;
+            tokens.push(token);
+        }
+        Ok((Self { tokens }, pos))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn sample_token(name: &str, expiry: u64) -> PersonalAccessTokenResponse {
+        PersonalAccessTokenResponse {
+            name: WireName::new(name).unwrap(),
+            expiry_at: expiry,
+        }
+    }
+
+    #[test]
+    fn token_roundtrip() {
+        let tok = sample_token("my-token", 1_710_000_000_000);
+        let bytes = tok.to_bytes();
+        assert_eq!(bytes.len(), 1 + 8 + 8);
+        let (decoded, consumed) = PersonalAccessTokenResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, tok);
+    }
+
+    #[test]
+    fn token_truncated_returns_error() {
+        let tok = sample_token("t", 0);
+        let bytes = tok.to_bytes();
+        for i in 0..bytes.len() {
+            assert!(
+                PersonalAccessTokenResponse::decode(&bytes[..i]).is_err(),
+                "expected error for truncation at byte {i}"
+            );
+        }
+    }
+
+    #[test]
+    fn list_roundtrip_empty() {
+        let resp = GetPersonalAccessTokensResponse { tokens: vec![] };
+        let bytes = resp.to_bytes();
+        assert!(bytes.is_empty());
+        let (decoded, consumed) = GetPersonalAccessTokensResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, 0);
+        assert_eq!(decoded, resp);
+    }
+
+    #[test]
+    fn list_roundtrip_multiple() {
+        let resp = GetPersonalAccessTokensResponse {
+            tokens: vec![sample_token("token-a", 100), sample_token("token-b", 0)],
+        };
+        let bytes = resp.to_bytes();
+        let (decoded, consumed) = GetPersonalAccessTokensResponse::decode(&bytes).unwrap();
+        assert_eq!(consumed, bytes.len());
+        assert_eq!(decoded, resp);
+    }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/personal_access_tokens/login_with_personal_access_token.rs
similarity index 89%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/personal_access_tokens/login_with_personal_access_token.rs
index e7b687f1f..1d26d075a 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/personal_access_tokens/login_with_personal_access_token.rs
@@ -15,4 +15,4 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+pub type LoginWithPersonalAccessTokenResponse = crate::responses::users::IdentityResponse;
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/responses/personal_access_tokens/mod.rs
similarity index 61%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/responses/personal_access_tokens/mod.rs
index 7b062a665..f204d2c2e 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/responses/personal_access_tokens/mod.rs
@@ -15,26 +15,15 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod streams;
+pub mod create_personal_access_token;
+mod delete_personal_access_token;
+pub mod get_personal_access_tokens;
+pub mod login_with_personal_access_token;
 
-use crate::WireError;
-use crate::codec::{WireDecode, WireEncode};
-use bytes::BytesMut;
-
-/// Marker type for commands that return an empty response payload.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
-
-impl WireEncode for EmptyResponse {
-    fn encoded_size(&self) -> usize {
-        0
-    }
-
-    fn encode(&self, _buf: &mut BytesMut) {}
-}
-
-impl WireDecode for EmptyResponse {
-    fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
-        Ok((Self, 0))
-    }
-}
+pub use super::EmptyResponse;
+pub use create_personal_access_token::RawPersonalAccessTokenResponse;
+pub use delete_personal_access_token::DeletePersonalAccessTokenResponse;
+pub use get_personal_access_tokens::{
+    GetPersonalAccessTokensResponse, PersonalAccessTokenResponse,
+};
+pub use login_with_personal_access_token::LoginWithPersonalAccessTokenResponse;
diff --git a/core/binary_protocol/src/responses/streams/get_stream.rs b/core/binary_protocol/src/responses/streams/get_stream.rs
index 936690eaf..3470e3743 100644
--- a/core/binary_protocol/src/responses/streams/get_stream.rs
+++ b/core/binary_protocol/src/responses/streams/get_stream.rs
@@ -17,7 +17,7 @@
 
 use crate::WireError;
 use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le, read_u64_le};
-use crate::identifier::WireName;
+use crate::primitives::identifier::WireName;
 use crate::responses::streams::StreamResponse;
 use bytes::{BufMut, BytesMut};
 
diff --git a/core/binary_protocol/src/responses/streams/stream_response.rs b/core/binary_protocol/src/responses/streams/stream_response.rs
index b6fbe02e9..aeeef4f58 100644
--- a/core/binary_protocol/src/responses/streams/stream_response.rs
+++ b/core/binary_protocol/src/responses/streams/stream_response.rs
@@ -17,7 +17,7 @@
 
 use crate::WireError;
 use crate::codec::{WireDecode, WireEncode, read_u32_le, read_u64_le};
-use crate::identifier::WireName;
+use crate::primitives::identifier::WireName;
 use bytes::{BufMut, BytesMut};
 
 /// Stream header on the wire. Used in both single-stream and multi-stream responses.
diff --git a/core/binary_protocol/src/responses/system/get_stats.rs b/core/binary_protocol/src/responses/system/get_stats.rs
new file mode 100644
index 000000000..ec9467007
--- /dev/null
+++ b/core/binary_protocol/src/responses/system/get_stats.rs
@@ -0,0 +1,427 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode, read_f32_le, read_str, read_u32_le, read_u64_le};
+use bytes::{BufMut, BytesMut};
+
+/// Per-partition cache hit/miss statistics.
+#[derive(Debug, Clone, PartialEq)]
+pub struct CacheMetricEntry {
+    pub stream_id: u32,
+    pub topic_id: u32,
+    pub partition_id: u32,
+    pub hits: u64,
+    pub misses: u64,
+    pub hit_ratio: f32,
+}
+
+impl CacheMetricEntry {
+    const SIZE: usize = 4 + 4 + 4 + 8 + 8 + 4; // 32
+}
+
+/// Server statistics response.
+///
+/// Wire format:
+/// ```text
+/// [process_id:4][cpu_usage:f32][total_cpu_usage:f32]
+/// [memory_usage:8][total_memory:8][available_memory:8]
+/// [run_time:8][start_time:8][read_bytes:8][written_bytes:8]
+/// [messages_size_bytes:8][streams_count:4][topics_count:4]
+/// [partitions_count:4][segments_count:4][messages_count:8]
+/// [clients_count:4][consumer_groups_count:4]
+/// [hostname_len:4][hostname:N]
+/// [os_name_len:4][os_name:N]
+/// [os_version_len:4][os_version:N]
+/// [kernel_version_len:4][kernel_version:N]
+/// [iggy_server_version_len:4][iggy_server_version:N]
+/// [iggy_server_semver:4]?
+/// [cache_metrics_count:4]
+/// For each: [stream_id:4][topic_id:4][partition_id:4][hits:8][misses:8][hit_ratio:f32]
+/// [threads_count:4]?
+/// [free_disk_space:8]?
+/// [total_disk_space:8]?
+/// ```
+#[derive(Debug, Clone, PartialEq)]
+pub struct StatsResponse {
+    pub process_id: u32,
+    pub cpu_usage: f32,
+    pub total_cpu_usage: f32,
+    pub memory_usage: u64,
+    pub total_memory: u64,
+    pub available_memory: u64,
+    pub run_time: u64,
+    pub start_time: u64,
+    pub read_bytes: u64,
+    pub written_bytes: u64,
+    pub messages_size_bytes: u64,
+    pub streams_count: u32,
+    pub topics_count: u32,
+    pub partitions_count: u32,
+    pub segments_count: u32,
+    pub messages_count: u64,
+    pub clients_count: u32,
+    pub consumer_groups_count: u32,
+    pub hostname: String,
+    pub os_name: String,
+    pub os_version: String,
+    pub kernel_version: String,
+    pub iggy_server_version: String,
+    pub iggy_server_semver: Option<u32>,
+    pub cache_metrics: Vec<CacheMetricEntry>,
+    pub threads_count: u32,
+    pub free_disk_space: u64,
+    pub total_disk_space: u64,
+}
+
+// Fixed-size numeric header before the variable-length string section.
+const NUMERIC_HEADER_SIZE: usize =
+    4 + 4 + 4 + 8 + 8 + 8 + 8 + 8 + 8 + 8 + 8 + 4 + 4 + 4 + 4 + 8 + 4 + 4; // 104
+
+fn encode_len_prefixed_str(buf: &mut BytesMut, s: &str) {
+    #[allow(clippy::cast_possible_truncation)]
+    buf.put_u32_le(s.len() as u32);
+    buf.put_slice(s.as_bytes());
+}
+
+fn decode_len_prefixed_str(buf: &[u8], pos: usize) -> Result<(String, usize), WireError> {
+    let len = read_u32_le(buf, pos)? as usize;
+    let s = read_str(buf, pos + 4, len)?;
+    Ok((s, pos + 4 + len))
+}
+
+impl WireEncode for StatsResponse {
+    fn encoded_size(&self) -> usize {
+        let tail = if self.iggy_server_semver.is_some() {
+            4 + 4 + 8 + 8 // semver + threads_count + free_disk_space + total_disk_space
+        } else {
+            0
+        };
+        NUMERIC_HEADER_SIZE
+            + (4 + self.hostname.len())
+            + (4 + self.os_name.len())
+            + (4 + self.os_version.len())
+            + (4 + self.kernel_version.len())
+            + (4 + self.iggy_server_version.len())
+            + 4 // cache_metrics_count
+            + self.cache_metrics.len() * CacheMetricEntry::SIZE
+            + tail
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        buf.put_u32_le(self.process_id);
+        buf.put_f32_le(self.cpu_usage);
+        buf.put_f32_le(self.total_cpu_usage);
+        buf.put_u64_le(self.memory_usage);
+        buf.put_u64_le(self.total_memory);
+        buf.put_u64_le(self.available_memory);
+        buf.put_u64_le(self.run_time);
+        buf.put_u64_le(self.start_time);
... 1470 lines suppressed ...