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

(iggy) branch remove_namespace_field updated: address review comments

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

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


The following commit(s) were added to refs/heads/remove_namespace_field by this push:
     new b1fea62dc address review comments
b1fea62dc is described below

commit b1fea62dc5205e469eba5e244c5997af2753db71
Author: Grzegorz Koszyk <g....@gmail.com>
AuthorDate: Tue Aug 11 10:33:34 2026 +0200

    address review comments
---
 core/binary_protocol/src/consensus/header.rs | 226 ++++++++++++++++-----------
 core/binary_protocol/src/version.rs          |   4 +-
 core/consensus/src/client_table.rs           |   7 +-
 core/metadata/src/stm/snapshot.rs            |  59 ++++++-
 core/server-ng/src/dispatch.rs               |   4 +-
 core/server_common/src/consensus_message.rs  | 104 +++++++++++-
 6 files changed, 294 insertions(+), 110 deletions(-)

diff --git a/core/binary_protocol/src/consensus/header.rs b/core/binary_protocol/src/consensus/header.rs
index f52e38aba..4f8fffe95 100644
--- a/core/binary_protocol/src/consensus/header.rs
+++ b/core/binary_protocol/src/consensus/header.rs
@@ -315,9 +315,9 @@ const _: () = {
 /// (it is derived: plane from `operation`, partition group from the body),
 /// so this is where the derivation result lives for the internal hop.
 ///
-/// Layout: identical to [`RequestHeader`] with `group` claiming the first
-/// eight reserved bytes, so the in-place `transmute_header` rewrite stays a
-/// same-size copy.
+/// Layout: identical to [`RequestHeader`] with `group` claiming the LAST
+/// eight reserved bytes (the tail, bytes 248..256); the leading 52 reserved
+/// bytes keep their client-wire meaning, so promotion is a same-size copy.
 #[repr(C)]
 #[derive(Debug, Clone, Copy, CheckedBitPattern, NoUninit)]
 pub struct RoutedRequestHeader {
@@ -368,7 +368,7 @@ impl Default for RoutedRequestHeader {
             size: 0,
             view: 0,
             release: 0,
-            command: Command2::Request,
+            command: Command2::Reserved,
             replica: 0,
             reserved_frame: [0; 66],
             client: 0,
@@ -410,44 +410,64 @@ impl Default for RequestHeader {
     }
 }
 
-impl RoutedRequestHeader {
-    /// Promote a client-wire [`RequestHeader`] to the server-internal routed
-    /// shape, stamping the resolved consensus `group`. Every shared field is
-    /// copied verbatim; this is the ONLY sanctioned crossing between the two
-    /// layouts (transmute-based reads across them would alias `group` with
-    /// reserved bytes).
-    #[must_use]
-    pub const fn from_request(header: &RequestHeader, group: u64) -> Self {
-        Self {
-            checksum: header.checksum,
-            checksum_body: header.checksum_body,
-            cluster: header.cluster,
-            size: header.size,
-            view: header.view,
-            release: header.release,
-            command: header.command,
-            replica: header.replica,
-            reserved_frame: header.reserved_frame,
-            client: header.client,
-            request_checksum: header.request_checksum,
-            timestamp: header.timestamp,
-            request: header.request,
-            operation: header.operation,
-            operation_padding: header.operation_padding,
-            session: header.session,
-            user_id: header.user_id,
-            reserved: {
-                let mut reserved = [0u8; 52];
-                let mut i = 0;
-                while i < 52 {
-                    reserved[i] = header.reserved[i];
-                    i += 1;
-                }
-                reserved
-            },
-            group,
+/// Field rules shared by the client-wire [`RequestHeader`] and the
+/// server-internal [`RoutedRequestHeader`]. The routed shape is decoded
+/// straight off the peer wire (`MessageBag`), so it must reject everything
+/// the client boundary rejects; validating only the command byte there
+/// would let a peer frame carry `client = 0` into the client table's hard
+/// assert, or `operation = Reserved` into a cached-register replay.
+fn validate_request_fields(
+    client: u128,
+    operation: Operation,
+    session: u64,
+    request: u64,
+) -> Result<(), ConsensusError> {
+    if client == 0 {
+        return Err(ConsensusError::InvalidField(
+            "request: client must be != 0".to_string(),
+        ));
+    }
+    // Reserved is the zero value, never a real client op
+    // (`is_client_allowed` rejects it). Refusing it here rather than after
+    // the dedup preflight matters: a bound client sending
+    // `Reserved, request = 0` used to pass validation, reach
+    // `request_preflight`, hit its own watermark and get its register
+    // reply replayed before the operation gate ever ran.
+    if operation == Operation::Reserved {
+        return Err(ConsensusError::InvalidField(
+            "operation must not be Reserved".to_string(),
+        ));
+    }
+    // Register: session must be 0, request must be 0.
+    // NonReplicated: sessionless by design (the `ClientTable` ignores
+    // these ops and the server routes/auth-gates them by transport id),
+    // so a pre-register client may legitimately send session 0 --
+    // ping must work before authentication.
+    // Other non-register ops: session must be > 0, request must be > 0.
+    if operation == Operation::Register {
+        if session != 0 {
+            return Err(ConsensusError::InvalidField(
+                "register: session must be 0".to_string(),
+            ));
+        }
+        if request != 0 {
+            return Err(ConsensusError::InvalidField(
+                "register: request must be 0".to_string(),
+            ));
+        }
+    } else if operation != Operation::NonReplicated {
+        if session == 0 {
+            return Err(ConsensusError::InvalidField(
+                "non-register: session must be > 0".to_string(),
+            ));
+        }
+        if request == 0 {
+            return Err(ConsensusError::InvalidField(
+                "non-register: request must be > 0".to_string(),
+            ));
         }
     }
+    Ok(())
 }
 
 impl ConsensusHeader for RoutedRequestHeader {
@@ -480,7 +500,7 @@ impl ConsensusHeader for RoutedRequestHeader {
                 found: self.command,
             });
         }
-        Ok(())
+        validate_request_fields(self.client, self.operation, self.session, self.request)
     }
 }
 
@@ -512,52 +532,7 @@ impl ConsensusHeader for RequestHeader {
                 found: self.command,
             });
         }
-        if self.client == 0 {
-            return Err(ConsensusError::InvalidField(
-                "request: client must be != 0".to_string(),
-            ));
-        }
-        // Reserved is the zero value, never a real client op
-        // (`is_client_allowed` rejects it). Refusing it here rather than after
-        // the dedup preflight matters: a bound client sending
-        // `Reserved, request = 0` used to pass validation, reach
-        // `request_preflight`, hit its own watermark and get its register
-        // reply replayed before the operation gate ever ran.
-        if self.operation == Operation::Reserved {
-            return Err(ConsensusError::InvalidField(
-                "operation must not be Reserved".to_string(),
-            ));
-        }
-        // Register: session must be 0, request must be 0.
-        // NonReplicated: sessionless by design (the `ClientTable` ignores
-        // these ops and the server routes/auth-gates them by transport id),
-        // so a pre-register client may legitimately send session 0 --
-        // ping must work before authentication.
-        // Other non-register ops: session must be > 0, request must be > 0.
-        if self.operation == Operation::Register {
-            if self.session != 0 {
-                return Err(ConsensusError::InvalidField(
-                    "register: session must be 0".to_string(),
-                ));
-            }
-            if self.request != 0 {
-                return Err(ConsensusError::InvalidField(
-                    "register: request must be 0".to_string(),
-                ));
-            }
-        } else if self.operation != Operation::NonReplicated {
-            if self.session == 0 {
-                return Err(ConsensusError::InvalidField(
-                    "non-register: session must be > 0".to_string(),
-                ));
-            }
-            if self.request == 0 {
-                return Err(ConsensusError::InvalidField(
-                    "non-register: request must be > 0".to_string(),
-                ));
-            }
-        }
-        Ok(())
+        validate_request_fields(self.client, self.operation, self.session, self.request)
     }
 }
 
@@ -2268,8 +2243,8 @@ mod tests {
         EvictionHeader, EvictionReason, GenericHeader, HEADER_SIZE, Operation, PrepareHeader,
         PrepareOkHeader, RepairPrepareHeader, RepairRangeReplyHeader, ReplyHeader, RequestHeader,
         RequestPreparesHeader, RequestStartViewHeader, RequestStateChunkHeader,
-        RequestStateTransferHeader, StartViewChangeHeader, StartViewHeader, StateChunkHeader,
-        StateTransferTargetHeader,
+        RequestStateTransferHeader, RoutedRequestHeader, StartViewChangeHeader, StartViewHeader,
+        StateChunkHeader, StateTransferTargetHeader,
     };
     use aligned_vec::{AVec, ConstAlign};
 
@@ -2533,17 +2508,88 @@ mod tests {
     }
 
     // `status` is carved from the reserved tail; the SDK reply funnel peeks it
-    // at this offset before any body decode, so a layout drift must trip here.
+    // at this offset before any body decode, and four foreign SDKs hardcode
+    // it, so a layout drift must trip here.
     #[test]
     fn reply_header_status_offset_and_size_pinned() {
         use std::mem::offset_of;
         assert_eq!(size_of::<ReplyHeader>(), HEADER_SIZE);
+        assert_eq!(offset_of!(ReplyHeader, status), 216);
         assert_eq!(
             offset_of!(ReplyHeader, reserved) + size_of::<[u8; 36]>(),
             HEADER_SIZE
         );
     }
 
+    // `group` claims the TAIL of the client header's reserved area; the
+    // leading 52 reserved bytes carry data (the non-replicated op code lives
+    // in `reserved[0..4]`), so a reshuffle of the carve must trip here rather
+    // than silently move the code range.
+    #[test]
+    fn routed_request_group_claims_reserved_tail() {
+        use std::mem::offset_of;
+        assert_eq!(
+            offset_of!(RoutedRequestHeader, reserved),
+            offset_of!(RequestHeader, reserved)
+        );
+        assert_eq!(
+            offset_of!(RoutedRequestHeader, group),
+            offset_of!(RequestHeader, reserved) + 52
+        );
+        assert_eq!(offset_of!(RoutedRequestHeader, group), 248);
+        assert_eq!(
+            offset_of!(RoutedRequestHeader, group) + size_of::<u64>(),
+            HEADER_SIZE
+        );
+    }
+
+    // The routed shape is decoded straight off the peer wire, so it must
+    // enforce the same field rules as the client boundary; a command-only
+    // validate lets a forged `client = 0` frame reach the client table's
+    // hard assert.
+    #[test]
+    fn routed_request_zero_client_rejected() {
+        let header = RoutedRequestHeader {
+            command: Command2::Request,
+            operation: Operation::SendMessages,
+            session: 10,
+            request: 1,
+            ..RoutedRequestHeader::default()
+        };
+        assert!(header.validate().is_err());
+    }
+
+    #[test]
+    fn routed_request_reserved_operation_rejected() {
+        let header = RoutedRequestHeader {
+            command: Command2::Request,
+            client: 0xCAFE,
+            session: 10,
+            request: 1,
+            ..RoutedRequestHeader::default()
+        };
+        assert!(header.validate().is_err());
+    }
+
+    #[test]
+    fn routed_request_default_fails_validate() {
+        assert!(RoutedRequestHeader::default().validate().is_err());
+    }
+
+    #[test]
+    fn routed_request_valid_fields_accepted() {
+        let header = RoutedRequestHeader {
+            command: Command2::Request,
+            operation: Operation::SendMessages,
+            client: 0xCAFE,
+            session: 10,
+            request: 1,
+            group: 7,
+            ..RoutedRequestHeader::default()
+        };
+        assert!(header.validate().is_ok());
+    }
+
     // A nonzero status rides the reserved region, which reply `validate` does
     // not inspect: a status-bearing reply stays valid so the SDK can peek it.
     #[test]
diff --git a/core/binary_protocol/src/version.rs b/core/binary_protocol/src/version.rs
index 9e7916c41..670c460b0 100644
--- a/core/binary_protocol/src/version.rs
+++ b/core/binary_protocol/src/version.rs
@@ -63,8 +63,8 @@
 //! `ClientVersionInfo` is the leading bytes of the login-register request
 //! *body*, which itself rides inside a 256-byte VSR `RequestHeader` (see
 //! `consensus::header`): `command` = `Command2::Request`, `operation` =
-//! `Operation::Register`, `namespace` = `METADATA_GROUP`,
-//! client id in `RequestHeader.client`. A foreign SDK emits that header,
+//! `Operation::Register`, client id in `RequestHeader.client`. The client
+//! sends no group; the server derives it. A foreign SDK emits that header,
 //! then the body starting with this prefix, to reach the gate.
 //!
 //! ## Login gate
diff --git a/core/consensus/src/client_table.rs b/core/consensus/src/client_table.rs
index 4413b8643..31c494d9f 100644
--- a/core/consensus/src/client_table.rs
+++ b/core/consensus/src/client_table.rs
@@ -1019,7 +1019,12 @@ impl From<Truncated> for ClientTableWireError {
 }
 
 /// Format tag for [`ClientTable::encode`]; bump on layout change.
-pub const CLIENT_TABLE_MAGIC: [u8; 4] = *b"ICT1";
+///
+/// That includes any `ReplyHeader` layout move -- cached replies are embedded
+/// as raw wire bytes, so an artifact written under an older header layout must
+/// be refused, not silently misread. `ICT2`: `status` sits at offset 216 (the
+/// pre-`ICT2` layout carried a `namespace` word before it).
+pub const CLIENT_TABLE_MAGIC: [u8; 4] = *b"ICT2";
 
 /// Per-entry fixed fields in the wire encoding: `client(u128) epoch(u64)
 /// user_id(u32) watermark(u64) watermark_checksum(u128) ring_len(u8)`.
diff --git a/core/metadata/src/stm/snapshot.rs b/core/metadata/src/stm/snapshot.rs
index ca28a30a4..e9fadca17 100644
--- a/core/metadata/src/stm/snapshot.rs
+++ b/core/metadata/src/stm/snapshot.rs
@@ -53,6 +53,10 @@ pub enum SnapshotError {
         commit_op: u64,
         table_frontier: u64,
     },
+    /// The snapshot was written under a different format version. Refuse it
+    /// rather than reinterpret embedded raw bytes (the client table's cached
+    /// replies are wire `ReplyHeader` frames) under the wrong layout.
+    UnsupportedVersion { found: u32, supported: u32 },
 }
 
 /// Stage at which snapshot persistence failed.
@@ -104,6 +108,13 @@ impl fmt::Display for SnapshotError {
                      commit_op {commit_op}, table frontier {table_frontier}"
                 )
             }
+            Self::UnsupportedVersion { found, supported } => {
+                write!(
+                    f,
+                    "unsupported metadata snapshot version {found}; this build reads only \
+                     version {supported}"
+                )
+            }
         }
     }
 }
@@ -116,7 +127,8 @@ impl std::error::Error for SnapshotError {
             Self::Io(e) | Self::Persist { source: e, .. } => Some(e),
             Self::ChecksumMismatch { .. }
             | Self::Truncated { .. }
-            | Self::IncoherentManifest { .. } => None,
+            | Self::IncoherentManifest { .. }
+            | Self::UnsupportedVersion { .. } => None,
         }
     }
 }
@@ -138,10 +150,17 @@ impl From<std::io::Error> for SnapshotError {
 /// replicas with identical state must serialize identically. Regression guards:
 /// `stream::tests::populated_streams_snapshot_reencode_is_byte_stable` and
 /// `impls::metadata::tests::populated_snapshot_reencode_and_checksum_are_stable`.
+/// Current [`MetadataSnapshot::version`]. Bump whenever the serialized form
+/// changes meaning without changing shape -- in particular the client table's
+/// cached replies, which are embedded as raw `ReplyHeader` wire bytes msgpack
+/// cannot introspect. Version 2: `status` sits at reply-header offset 216
+/// (version 1 carried a `namespace` word before it).
+pub const METADATA_SNAPSHOT_VERSION: u32 = 2;
+
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct MetadataSnapshot {
-    /// Snapshot format version for forward/backward compatibility.
-    /// TODO(krishvishal): Properly handle versioning for snapshot. This is a placeholder for now.
+    /// Snapshot format version; [`MetadataSnapshot::decode`] refuses any other
+    /// value (see [`METADATA_SNAPSHOT_VERSION`]).
     pub version: u32,
     /// Timestamp when the snapshot was created (microseconds since epoch).
     pub created_at: u64,
@@ -171,7 +190,7 @@ impl MetadataSnapshot {
     #[must_use]
     pub const fn new(sequence_number: u64) -> Self {
         Self {
-            version: 1,
+            version: METADATA_SNAPSHOT_VERSION,
             // Deterministic placeholder. The real creation time is stamped by
             // `Snapshot::create` from the consensus-injected clock (see
             // `VsrConsensus::clock_realtime_micros`) so a replayed simulator
@@ -196,9 +215,18 @@ impl MetadataSnapshot {
     /// Decode a snapshot from msgpack bytes.
     ///
     /// # Errors
-    /// Returns `SnapshotError::Deserialize` if msgpack deserialization fails.
+    /// Returns `SnapshotError::Deserialize` if msgpack deserialization fails,
+    /// or `SnapshotError::UnsupportedVersion` if the snapshot was written
+    /// under a different format version.
     pub fn decode(bytes: &[u8]) -> Result<Self, SnapshotError> {
-        rmp_serde::from_slice(bytes).map_err(SnapshotError::Deserialize)
+        let snapshot: Self = rmp_serde::from_slice(bytes).map_err(SnapshotError::Deserialize)?;
+        if snapshot.version != METADATA_SNAPSHOT_VERSION {
+            return Err(SnapshotError::UnsupportedVersion {
+                found: snapshot.version,
+                supported: METADATA_SNAPSHOT_VERSION,
+            });
+        }
+        Ok(snapshot)
     }
 }
 
@@ -440,6 +468,25 @@ mod tests {
         assert!(decoded.client_table.is_none());
     }
 
+    // The client table's cached replies are embedded as raw `ReplyHeader`
+    // wire bytes msgpack cannot introspect, so a snapshot from a different
+    // format version must be refused, never reinterpreted under the current
+    // header layout.
+    #[test]
+    fn decode_refuses_a_snapshot_from_another_format_version() {
+        let mut snapshot = MetadataSnapshot::new(42);
+        snapshot.version = METADATA_SNAPSHOT_VERSION - 1;
+
+        let encoded = snapshot.encode().unwrap();
+        assert!(matches!(
+            MetadataSnapshot::decode(&encoded),
+            Err(SnapshotError::UnsupportedVersion {
+                found,
+                supported: METADATA_SNAPSHOT_VERSION,
+            }) if found == METADATA_SNAPSHOT_VERSION - 1
+        ));
+    }
+
     #[test]
     fn roundtrip_with_data() {
         let ts = IggyTimestamp::from(1_694_968_446_131_680_u64);
diff --git a/core/server-ng/src/dispatch.rs b/core/server-ng/src/dispatch.rs
index da40c303e..73769fe3f 100644
--- a/core/server-ng/src/dispatch.rs
+++ b/core/server-ng/src/dispatch.rs
@@ -867,9 +867,7 @@ async fn handle_client_request<B, MJ, S, SB>(
     // client wire carries no group (it is derived -- plane from `operation`,
     // partition target from the payload), so it starts unset here and the
     // resolution sites below stamp it before anything routes on it.
-    let request = request.transmute_header(|header, new_header: &mut RoutedRequestHeader| {
-        *new_header = RoutedRequestHeader::from_request(&header, 0);
-    });
+    let request = request.into_routed();
 
     // The last point that still sees the body the CLIENT sent; every rewrite below
     // substitutes server-chosen bytes and carries the stamp through unchanged.
diff --git a/core/server_common/src/consensus_message.rs b/core/server_common/src/consensus_message.rs
index 6180853f9..66f973a99 100644
--- a/core/server_common/src/consensus_message.rs
+++ b/core/server_common/src/consensus_message.rs
@@ -19,12 +19,15 @@ use crate::iobuf::{Frozen, Owned};
 use iggy_binary_protocol::{
     Command2, CommitHeader, ConsensusError, ConsensusHeader, DoViewChangeHeader, GenericHeader,
     Operation, PrepareHeader, PrepareOkHeader, RepairPrepareHeader, RepairRangeReplyHeader,
-    RequestPreparesHeader, RequestStartViewHeader, RequestStateChunkHeader,
+    RequestHeader, RequestPreparesHeader, RequestStartViewHeader, RequestStateChunkHeader,
     RequestStateTransferHeader, RoutedRequestHeader, StartViewChangeHeader, StartViewHeader,
     StateChunkHeader, StateTransferTargetHeader,
 };
 use smallvec::SmallVec;
-use std::{marker::PhantomData, mem::size_of};
+use std::{
+    marker::PhantomData,
+    mem::{offset_of, size_of},
+};
 
 pub const MESSAGE_ALIGN: usize = 4096;
 
@@ -378,6 +381,31 @@ where
     }
 }
 
+impl Message<RequestHeader> {
+    /// Retype the client-wire request into the server-internal
+    /// [`RoutedRequestHeader`] shape in place, with `group` starting unset.
+    ///
+    /// The two layouts share every field offset (const-asserted where they
+    /// are declared) and `group` claims the client header's reserved tail,
+    /// so the promotion zeroes those eight bytes instead of rebuilding the
+    /// whole 256-byte header. This is the only sanctioned crossing between
+    /// the two layouts: transmute-based reads across them would alias
+    /// `group` with reserved bytes a client may have sent nonzero.
+    ///
+    /// # Panics
+    ///
+    /// Panics if the retyped header fails [`RoutedRequestHeader`] validation;
+    /// unreachable when `self` already passed [`RequestHeader`] validation,
+    /// which enforces the same field rules.
+    #[must_use]
+    pub fn into_routed(self) -> Message<RoutedRequestHeader> {
+        let group_offset = offset_of!(RoutedRequestHeader, group);
+        let mut owned = self.into_owned();
+        owned.as_mut_slice()[group_offset..group_offset + size_of::<u64>()].fill(0);
+        Message::try_from(owned).expect("retyped request message must stay valid")
+    }
+}
+
 impl<H> Message<H, ResponseBacking>
 where
     H: ConsensusHeader,
@@ -889,9 +917,7 @@ mod tests {
     #[test]
     fn try_as_typed_invalid_validation_returns_err() {
         // `RequestHeader::validate` rejects operation=Register with non-zero
-        // session. The client-wire header is the one carrying the field rules;
-        // `RoutedRequestHeader` is the post-resolution shape and gates on the
-        // command alone.
+        // session; the routed shape shares the same field rules.
         let mut owned = header_bytes(Command2::Request, 256);
         {
             let buf = owned.as_mut_slice();
@@ -975,9 +1001,6 @@ mod tests {
     #[test]
     fn client_wire_decode_of_request_with_invalid_register_session_returns_err() {
         // `RequestHeader::validate` rejects Register with non-zero session.
-        // Not routed through `MessageBag`: that path decodes the already-routed
-        // `RoutedRequestHeader` for replica-to-replica frames, and the field
-        // rules are the client boundary's job.
         let mut owned = header_bytes(Command2::Request, 256);
         {
             let buf = owned.as_mut_slice();
@@ -1079,6 +1102,71 @@ mod tests {
         assert_eq!(prepared.header().command, Command2::Prepare);
     }
 
+    // into_routed: in-place client-wire -> routed retype
+
+    // Promotion must carry the data-bearing reserved prefix verbatim (the
+    // non-replicated op code lives in `reserved[0..4]`) and unset only the
+    // `group` tail, whatever junk the client sent in those eight bytes.
+    #[test]
+    fn into_routed_keeps_reserved_prefix_and_unsets_group() {
+        const RESERVED_OFF: usize = std::mem::offset_of!(RequestHeader, reserved);
+
+        let mut owned = header_bytes(Command2::Request, 256);
+        {
+            let buf = owned.as_mut_slice();
+            for (index, byte) in buf[RESERVED_OFF..RESERVED_OFF + 60].iter_mut().enumerate() {
+                *byte = u8::try_from(index).expect("60 fits u8") + 1;
+            }
+        }
+        let request = Message::<RequestHeader>::try_from(owned).expect("valid client frame");
+        let client_header = *request.header();
+
+        let routed = request.into_routed();
+        let header = routed.header();
+        assert_eq!(
+            header.reserved[..],
+            client_header.reserved[..52],
+            "the reserved prefix carries data and must survive promotion"
+        );
+        assert_eq!(
+            header.group, 0,
+            "the client-sent reserved tail must not leak into `group`"
+        );
+        assert_eq!(header.client, client_header.client);
+        assert_eq!(header.operation, client_header.operation);
+        assert_eq!(header.session, client_header.session);
+        assert_eq!(header.request, client_header.request);
+        assert_eq!(header.user_id, client_header.user_id);
+    }
+
+    // A peer-wire `Command2::Request` decodes as `RoutedRequestHeader`, so its
+    // validate must enforce the client-boundary field rules: a forged
+    // `client = 0` frame would otherwise reach the client table's hard assert
+    // and abort the metadata primary, and a `Reserved` operation would replay
+    // that client's cached register reply.
+    #[test]
+    fn messagebag_dispatch_rejects_request_with_zero_client() {
+        let mut owned = header_bytes(Command2::Request, 256);
+        owned.as_mut_slice()[REQUEST_CLIENT_OFF..REQUEST_CLIENT_OFF + 16]
+            .copy_from_slice(&0u128.to_le_bytes());
+        let generic = Message::<GenericHeader>::try_from(owned).expect("valid generic");
+        assert!(matches!(
+            MessageBag::try_from(generic),
+            Err(ConsensusError::InvalidField(_))
+        ));
+    }
+
+    #[test]
+    fn messagebag_dispatch_rejects_request_with_reserved_operation() {
+        let mut owned = header_bytes(Command2::Request, 256);
+        owned.as_mut_slice()[REQUEST_OPERATION_OFF] = Operation::Reserved as u8;
+        let generic = Message::<GenericHeader>::try_from(owned).expect("valid generic");
+        assert!(matches!(
+            MessageBag::try_from(generic),
+            Err(ConsensusError::InvalidField(_))
+        ));
+    }
+
     // ResponseBacking via SmallVec<Frozen>
 
     #[test]