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

spetz pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iggy-website.git


The following commit(s) were added to refs/heads/main by this push:
     new f369abf5 docs(site): rework protocol docs into Binary Protocol section 
(#67)
f369abf5 is described below

commit f369abf52056f0a96060d4dfb4ef5fc595a7cfe3
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Thu Aug 20 14:09:28 2026 +0200

    docs(site): rework protocol docs into Binary Protocol section (#67)
    
    The 857-line schema page sat buried under Server and mixed every
    protocol concern, the replica-to-replica wire was undocumented, and
    the storage-engine write-pipeline diagram rendered unreadably while
    contradicting the code.
    
    Schema content now lives in a top-level Binary Protocol section
    split by concern (framing, connection lifecycle, commands, shared
    encodings, message batches), plus a new server-to-server page
    written against the consensus code: command registry, frame seals,
    handshake, replication, view changes, repair, state transfer, and
    session forwarding. A meta-refresh stub keeps the old
    /docs/server/schema/ URL alive since the static export cannot
    redirect.
    
    The write-pipeline diagram is redrawn without the back-edge cycle
    and long edge label that scrambled the site's mermaid renderer, and
    its claims are corrected against the server: stamping happens at
    journal-append time before replication acks, buffering at prepare
    time, the flush gate is a three-way OR evaluated on commit events,
    and enforce_fsync does fdatasync on both the .log and .index.
    Inherited errors fixed along the way: the header-kinds table was
    missing code 8 (Int128); the server-stamped message time is the
    flat batch base_timestamp, not base_timestamp + timestamp_delta;
    and compression_algorithm is now documented as a placeholder (the
    value is stored and reported, but nothing compresses payloads yet -
    the message-headers examples show the client-side pattern).
    
    Two rendering fixes ride along. The mermaid component bumped
    font-size attributes after layout, overflowing node boxes sized for
    the original metrics and colliding adjacent nodes; it now zooms the
    whole SVG uniformly instead. And the fumadocs mobile TOC bar
    ([data-toc-popover]) is lifted to z-30 with an opaque background:
    it was sticky at z-10 and transparent, so docs content using z-10
    painted over it while scrolling under.
---
 content/docs/binary-protocol/cluster.mdx           | 169 ++++
 content/docs/binary-protocol/commands.mdx          | 425 ++++++++++
 .../docs/binary-protocol/connection-lifecycle.mdx  |  65 ++
 content/docs/binary-protocol/encodings.mdx         | 127 +++
 content/docs/binary-protocol/framing.mdx           | 194 +++++
 content/docs/binary-protocol/index.mdx             |  20 +
 content/docs/binary-protocol/messages.mdx          |  41 +
 content/docs/binary-protocol/meta.json             |   4 +
 content/docs/introduction/concepts.mdx             |   4 +-
 content/docs/meta.json                             |   1 +
 content/docs/server/meta.json                      |   2 +-
 content/docs/server/networking.mdx                 |   2 +-
 content/docs/server/schema.mdx                     | 857 ---------------------
 content/docs/server/storage-engine.mdx             |  43 +-
 content/docs/server/topic-options.mdx              |   2 +-
 public/docs/server/schema/index.html               |  31 +
 src/app/global.css                                 |   8 +
 src/components/architecture-diagrams.tsx           |   2 +-
 src/components/mermaid.tsx                         |  13 +-
 19 files changed, 1121 insertions(+), 889 deletions(-)

diff --git a/content/docs/binary-protocol/cluster.mdx 
b/content/docs/binary-protocol/cluster.mdx
new file mode 100644
index 00000000..0d0e2aff
--- /dev/null
+++ b/content/docs/binary-protocol/cluster.mdx
@@ -0,0 +1,169 @@
+---
+title: Server-to-server
+---
+
+Replicas talk to each other with the same 256-byte 
[framing](/docs/binary-protocol/framing) the client protocol uses, on a 
**dedicated TCP port**, with their own set of `command` discriminants. None of 
these frames ever appears on a client connection, and a client cannot reach 
this plane: it is a separate listener, gated by a handshake.
+
+This page documents the replica plane as of binary protocol 0.11.0. It is 
internal protocol: only Iggy servers speak it, and it can change between server 
releases without a client-facing version bump.
+
+## Transport
+
+- Every node in `[[cluster.nodes]]` exposes a `tcp_replica` port next to the 
client ports: `ports = { tcp = 8090, quic = 8080, http = 3000, websocket = 
8092, tcp_replica = 9090 }`. The node's `ip` is the roster address for the 
replica plane (clients fall back to it when `advertised_address` is unset).
+- The replica plane is **TCP only**, by design: the prepare hash chain, 
cross-shard fd delegation, and view-change timing all assume an ordered byte 
stream.
+- **Directional dialing:** a replica dials only peers with a strictly greater 
`replica_id` and accepts inbound connections only from strictly lower ids. 
Exactly one connection exists per pair, with no tiebreaker races.
+- Frames are the standard `[256-byte header][optional body]` with `size` at 
offset 48. The maximum frame is **64 MiB** by default 
(`message_bus.max_message_size`). Headers are `#[repr(C)]`, decoded zero-copy, 
and require 16-byte alignment.
+- Nothing inside a frame marks it as replica traffic. The separation is 
structural: client listeners parse every inbound frame as a `RequestHeader` 
(command `5` only), the replica listener requires the first frame to be 
`ReplicaHello`, and the client-bound commands `Reply` (8) and `Eviction` (13) 
are rejected if they arrive on the replica plane.
+
+## Command discriminants
+
+The full `command` byte registry, client values included. Values above 29 are 
rejected.
+
+| Value | Command | Direction | Purpose |
+|-------|---------|-----------|---------|
+| 0 | `Reserved` | - | Invalid sentinel |
+| 1-4 | `Ping`, `Pong`, `PingClient`, `PongClient` | - | Reserved; no 
production traffic today |
+| 5 | `Request` | client to server | Client command 
([framing](/docs/binary-protocol/framing)) |
+| 6 | `Prepare` | primary to backup, backup to next backup | Replicate one 
operation |
+| 7 | `PrepareOk` | backup to primary | Acknowledge a prepare |
+| 8 | `Reply` | server to client | Client reply; rejected on the replica plane 
|
+| 9 | `Commit` | primary to all | Commit-point broadcast, doubles as heartbeat 
|
+| 10 | `StartViewChange` | any to all | View-change proposal |
+| 11 | `DoViewChange` | any to all | View-change vote with log suffix |
+| 12 | `StartView` | new primary to all | Install the new view |
+| 13 | `Eviction` | server to client | Client eviction; rejected on the 
replica plane |
+| 14 | `ReplicaHello` | dialer to acceptor | Handshake step 1 |
+| 15 | `ReplicaChallenge` | acceptor to dialer | Handshake step 2 |
+| 16 | `ReplicaFinish` | dialer to acceptor | Handshake step 3 |
+| 17 | `RequestStartView` | recovering replica to all | Ask the current 
primary for a targeted `StartView` |
+| 18 | `RequestPrepares` | lagging replica to peer | Journal repair: request 
an op range |
+| 19 | `RepairPrepare` | serving peer to requester | One journaled prepare, 
verbatim |
+| 20 | `RepairDone` | serving peer to requester | Repair stream terminator |
+| 21 | `RangeEvicted` | serving peer to requester | Range no longer retained |
+| 22 | `RequestStateTransfer` | requester to primary | Bulk catch-up: ask for 
the target |
+| 23 | `StateTransferTarget` | primary to requester | Offer + state manifest |
+| 24 | `RequestStateChunk` | requester to primary | Fetch one artifact chunk |
+| 25 | `StateChunk` | primary to requester | Artifact bytes |
+| 26 | `ForwardRegister` | backup to primary | Forward a verified login |
+| 27 | `ForwardRegisterResult` | primary to backup | Login outcome |
+| 28 | `ForwardLogout` | backup to primary | Forward a logout |
+| 29 | `ForwardLogoutResult` | primary to backup | Logout outcome |
+
+There is no dedicated replica heartbeat: liveness is the `Commit` broadcast, 
sent on `cluster.commit_broadcast_interval`.
+
+## Frame integrity
+
+The `checksum` and `checksum_body` fields that client frames leave zero are 
live on the replica plane:
+
+- **Frame seal** (`checksum`, bytes 0..16): XxHash3-64 over header bytes 
16..256, widened to u128. Sealed on every replica message **except** `Prepare`, 
`RepairPrepare`, and the three handshake frames (whose integrity comes from the 
keyed MAC). Verified once, at typed decode, before any field validation; a 
mismatch drops the frame.
+- **Prepare identity** (`Prepare` / `RepairPrepare` only): those two spend 
`checksum` on an identity hash instead - XxHash3-64 over the whole 256-byte 
header computed with `checksum = 0` and `view = 0`, so a retransmit that 
re-stamps `view` keeps the same identity. `PrepareOk` echoes it in 
`prepare_checksum`, and each prepare's `parent` field carries the previous 
prepare's identity, forming a hash chain.
+- **Body seal** (`checksum_body`, bytes 16..32): three regimes. Metadata-plane 
prepares seal their body with XxHash3-64. Partition-plane prepares leave it 
zero: the message batch inside already carries `batch_checksum`, verified at 
network ingress. `DoViewChange` and `StartView` seal their suffix bodies. Every 
other message leaves it zero (state-transfer bodies are verified per artifact, 
not per frame).
+- The seals are **unkeyed integrity checks, not authentication**. Peer 
authentication comes from the handshake below; without `cluster.auth` and TLS 
enabled, the replica port trusts any peer that can reach it. Do not expose 
`tcp_replica` beyond the cluster network.
+
+`Prepare`, `RepairPrepare`, and the session-forwarding headers validate their 
reserved regions as zero; the other replica headers leave reserved bytes 
covered by the frame seal only.
+
+## Handshake
+
+Commands 14-16, exchanged before any consensus traffic. These are raw 
`GenericHeader` frames (`size = 256`) using the per-command area at bytes 
128..256:
+
+| Offset | Size | Content |
+|--------|------|---------|
+| 128 | 32 | Nonce (dialer's in `Hello`, acceptor's in `Challenge`) |
+| 160 | 32 | BLAKE3 keyed MAC (acceptor's in `Challenge`, dialer's in 
`Finish`) |
+| 192 | 1 | `Challenge` only: handshake status |
+
+Statuses: `0` Ok, `1` UnknownCommand, `2` ClusterMismatch, `3` 
DirectionalRule; values `4` and `5` are reserved (never sent), and a dialer 
treats any unknown status byte as a rejection. The MAC key derives from 
`cluster.auth.shared_secret` (32+ bytes; `previous_shared_secret` gives a 
rotation window), and with cluster TLS enabled (TLS 1.3 only, ALPN 
`iggy-replica`) the TLS exporter is folded into every MAC as channel binding. 
The handshake authenticates **cluster membership**, not per-re [...]
+
+The `cluster` header field (bytes 32..48) is the first 16 bytes of 
`blake3(cluster_name)` as a little-endian u128 on every replica frame, checked 
during the handshake, so nodes from a differently named cluster cannot connect 
even with auth disabled.
+
+## Replication
+
+### Prepare (6)
+
+Per-command fields (bytes 128..256):
+
+| Offset | Size | Field | Description |
+|--------|------|-------|-------------|
+| 128 | 16 | `client` | Originating client id |
+| 144 | 16 | `parent` | Previous prepare's identity checksum (hash chain) |
+| 160 | 16 | `request_checksum` | Verbatim from the admitted request |
+| 176 | 8 | `op` | Log position |
+| 184 | 8 | `commit` | Primary's commit point when the prepare was built |
+| 192 | 8 | `timestamp` | Primary-assigned monotonic timestamp |
+| 200 | 8 | `request` | Client request number |
+| 208 | 1 | `operation` | Operation discriminant (+7 padding) |
+| 216 | 8 | `group` | Consensus group (see [planes](#consensus-planes)) |
+| 224 | 4 | `user_id` | Authenticated user |
+| 228 | 28 | reserved | Zero |
+
+For metadata operations the body is the admitted command's payload 
**verbatim**. For `SendMessages` it is the [message 
batch](/docs/binary-protocol/messages) as stamped by the primary at journal 
append (`base_offset`, `base_timestamp`, and the recomputed `batch_checksum` 
are final); each backup re-derives the expected stamp from its own position and 
refuses a mismatch. Replication is **chain-form**: the primary sends to 
`(replica + 1) % replica_count`, each backup forwards to its own suc [...]
+
+### PrepareOk (7)
+
+Header-only. `parent` (echo) at 128, `prepare_checksum` (echo of the prepare's 
identity) at 144, then `op` 160, `commit` 168, `timestamp` 176, `request` 184, 
`operation` 192, `group` 200. Two fields are the acker's own, not echoes: 
header `view` is the acking replica's view, and `commit` is its own commit 
point. Sent to the view's primary.
+
+### Commit (9)
+
+Header-only broadcast; validation rejects `size != 256`. `timestamp_monotonic` 
at 144 is the liveness token (a receiver resets its heartbeat timer only on a 
strictly newer value), `commit` at 152 is the commit point, `group` at 168. 
Bytes 128..144 and 160..168 are declared (`commit_checksum`, `checkpoint_op`) 
but currently unused.
+
+## View changes
+
+- **StartViewChange (10):** header-only, just `group` at 128. Broadcast when a 
replica suspects the primary.
+- **DoViewChange (11):** `op` (highest op) 128, `commit` 136, `group` 144, 
`log_view` 152 (view when the sender was last in Normal status), `nack_bitset` 
224 (bit i = "I never prepared suffix entry i"; truncation authority), 
`present_bitset` 240 (bit i = "I can serve entry i's body"). Body = the log 
suffix as up to **128 packed 256-byte `PrepareHeader`s**, headers only. 
Broadcast.
+- **StartView (12):** `op` 128, `commit` 136 (max over the DVC quorum), 
`group` 144, `incarnation` 240 (echo of a `RequestStartView` incarnation, zero 
when unsolicited). Body = the view's suffix as packed `PrepareHeader`s, highest 
op first; empty is legal. Unicast when answering a probe, broadcast otherwise.
+- **RequestStartView (17):** header-only recovery probe with `group` 128 and 
`incarnation` 240. A restarted replica broadcasts it instead of trusting stale 
local state; only the current view's primary answers, with a targeted 
`StartView`.
+
+## Journal repair
+
+A replica missing committed prepares (rejoin window or interior hole) pulls 
them from a Normal-status peer (the metadata plane also serves repair during a 
view change, so a forming primary cannot deadlock):
+
+- **RequestPrepares (18):** `nonce` 128, `from_op` 144, `to_op` 152, `group` 
160. `from_op = 0` or `from_op > to_op` is rejected.
+- **RepairPrepare (19):** the journaled prepares, streamed in op order.
+- **RepairDone (20)** / **RangeEvicted (21):** one shared layout (`nonce` 128, 
`op` 144, `group` 152) under two command bytes. `RepairDone.op` = last op 
served; `RangeEvicted.op` = oldest op the peer still retains, the honest answer 
when the front of the range is gone.
+
+Pacing is bounded by `cluster.repair_chunk_max`, which must stay below 
`message_bus.peer_queue_capacity`: per-peer send queues drop overruns silently.
+
+## State transfer
+
+Bulk catch-up for a replica too far behind for journal repair. **Pull-based 
and lockstep** (at most one chunk in flight per artifact), because per-peer 
queues drop overruns:
+
+1. **RequestStateTransfer (22):** `nonce` 128, `group` 144.
+2. **StateTransferTarget (23):** `nonce` 128, `commit_op` 144, `group` 152, 
`available` 160, `unavailable_transient` 161, `commit_max` 168. `available = 0` 
is a header-only refusal. Body = the **state manifest**.
+3. **RequestStateChunk (24):** `nonce` 128, `offset` 144, `group` 152, `len` 
160, `artifact` 164. `len` describes the reply and is clamped by the server.
+4. **StateChunk (25):** `nonce` 128, `offset` 144, `group` 152, `artifact` 
160. Body = raw artifact bytes. Chunks carry no per-chunk integrity; each 
artifact is verified whole, by length and XxHash3-64, against its manifest 
entry.
+
+The manifest (little-endian):
+
+```text
+[magic: "ISM1"][count: u32][entry_len: u8]
+count x { kind: u8, frontier: u64, len: u64, checksum: u64 }   # entry_len = 25
+[trailer: XxHash3-64 over everything above]
+```
+
+At most 65536 entries. Entries longer than 25 bytes decode with the tail 
skipped (forward compatibility); shorter entries are rejected.
+
+Artifact kinds:
+
+| Kind | Artifact | Plane | Content |
+|------|----------|-------|---------|
+| 0 | `METADATA_SNAPSHOT` | metadata | `snapshot.bin` verbatim (MessagePack, 
snapshot format version 3); `frontier` = sequence number |
+| 1 | `CLIENT_TABLE` | metadata | Client table encoding (magic `ICT2`); 
`frontier` = mutation frontier |
+| 2 | `SEGMENT_LOG` | partition | One retained segment's `.log` verbatim; 
`frontier` = segment base offset |
+| 3 | `CONSUMER_OFFSETS` | partition | Consumer + group offset tables and 
applied purge generation; `frontier` = offer's `commit_op` |
+
+## Session forwarding
+
+A client may dial a backup; authentication happens there, and only the 
verified identity travels to the primary (credentials never cross the replica 
plane):
+
+- **ForwardRegister (26):** `client` 128, `nonce` 144, `user_id` 160.
+- **ForwardRegisterResult (27):** `nonce` 128, `client` 144, `epoch` 160, 
`watermark` 168, outcome byte at **255**: `0` Ok, `1` NotPrimary, `2` 
NotCaughtUp, `3` PipelineFull, `4` InProgress, `5` Canceled, `6` 
ClientIdOwnedByAnotherUser. `epoch` and `watermark` must be zero on any non-Ok 
outcome.
+- **ForwardLogout (28):** `client` 128, `nonce` 144, `session` 160, `request` 
168.
+- **ForwardLogoutResult (29):** `nonce` 128, `client` 144, `commit` 160, 
outcome at 255: `0` Ok, `1` NotPrimary, `2` PipelineFull, `3` InProgress, `4` 
Canceled.
+
+## Consensus planes
+
+Two consensus planes share this one message set; there are no plane-specific 
commands. Routing is by the `group: u64` field carried on every consensus 
header except the handshake and session-forwarding frames (those are implicitly 
metadata-plane):
+
+- `group = 1 << 63` is the **metadata plane** (streams, topics, users, 
consumer groups; durable on-disk journal).
+- Any other value is a packed stream/topic/partition key: a **partition 
plane** group (message batches, consumer offsets; in-memory journal).
+
+Same wire, a few divergent semantics: partition-plane prepares leave 
`checksum_body` zero and rely on the batch's own `batch_checksum`, their 
identity checksum covers the header alone, and 
`StateTransferTarget.unavailable_transient` / `commit_max` are read only by the 
partition arm.
diff --git a/content/docs/binary-protocol/commands.mdx 
b/content/docs/binary-protocol/commands.mdx
new file mode 100644
index 00000000..5a9687c6
--- /dev/null
+++ b/content/docs/binary-protocol/commands.mdx
@@ -0,0 +1,425 @@
+---
+title: Commands
+---
+
+## Catalog
+
+The u32 command codes. Replicated commands are identified on the wire by their 
`operation` byte (see [Operation 
discriminants](/docs/binary-protocol/framing#operation-discriminants)). The 
code column below is the protocol-level registry and, for non-replicated 
commands, the value carried in header bytes 196..200.
+
+```bash
+# System
+PING = 1                                  # non-replicated; works without login
+GET_STATS = 10                            # non-replicated
+GET_SNAPSHOT = 11                         # non-replicated
+GET_CLUSTER_METADATA = 12                 # non-replicated
+DESCRIBE_OPTIONS = 13                     # non-replicated
+GET_ME = 20                               # non-replicated
+GET_CLIENT = 21                           # non-replicated
+GET_CLIENTS = 22                          # non-replicated
+
+# Users
+GET_USER = 31                             # non-replicated
+GET_USERS = 32                            # non-replicated
+CREATE_USER = 33                          # operation 141
+DELETE_USER = 34                          # operation 143
+UPDATE_USER = 35                          # operation 142
+UPDATE_PERMISSIONS = 36                   # operation 145
+CHANGE_PASSWORD = 37                      # operation 144
+LOGIN_USER = 38                           # legacy; server rejects with 
MalformedLogin eviction
+LOGOUT_USER = 39                          # operation 3 (Logout)
+LOGIN_REGISTER = 40                       # operation 1 (Register)
+LOGIN_REGISTER_WITH_PAT = 45              # operation 1 (Register)
+
+# Personal access tokens
+GET_PERSONAL_ACCESS_TOKENS = 41           # non-replicated
+CREATE_PERSONAL_ACCESS_TOKEN = 42         # operation 146
+DELETE_PERSONAL_ACCESS_TOKEN = 43         # operation 147
+LOGIN_WITH_PERSONAL_ACCESS_TOKEN = 44     # legacy; server rejects with 
MalformedLogin eviction
+
+# Messages
+POLL_MESSAGES = 100                       # non-replicated
+SEND_MESSAGES = 101                       # operation 160
+FLUSH_UNSAVED_BUFFER = 102                # parses, but always fails with 
FeatureUnavailable
+
+# Consumer offsets
+GET_CONSUMER_OFFSET = 120                 # non-replicated
+STORE_CONSUMER_OFFSET = 121               # operation 161
+DELETE_CONSUMER_OFFSET = 122              # operation 162
+
+# Streams
+GET_STREAM = 200                          # non-replicated
+GET_STREAMS = 201                         # non-replicated
+CREATE_STREAM = 202                       # operation 128
+DELETE_STREAM = 203                       # operation 130
+UPDATE_STREAM = 204                       # operation 129
+PURGE_STREAM = 205                        # operation 131
+
+# Topics
+GET_TOPIC = 300                           # non-replicated
+GET_TOPICS = 301                          # non-replicated
+CREATE_TOPIC = 302                        # operation 132
+DELETE_TOPIC = 303                        # operation 134
+UPDATE_TOPIC = 304                        # operation 133
+PURGE_TOPIC = 305                         # operation 135
+
+# Partitions
+CREATE_PARTITIONS = 402                   # operation 136
+DELETE_PARTITIONS = 403                   # operation 137
+
+# Segments
+DELETE_SEGMENTS = 503                     # operation 138
+
+# Consumer groups
+GET_CONSUMER_GROUP = 600                  # non-replicated
+GET_CONSUMER_GROUPS = 601                 # non-replicated
+CREATE_CONSUMER_GROUP = 602               # operation 139
+DELETE_CONSUMER_GROUP = 603               # operation 140
+JOIN_CONSUMER_GROUP = 604                 # operation 148
+LEAVE_CONSUMER_GROUP = 605                # operation 149
+SYNC_CONSUMER_GROUP = 606                 # non-replicated
+```
+
+`FLUSH_UNSAVED_BUFFER` still decodes on the wire, but the server has no 
on-demand flush primitive and answers every call with `FeatureUnavailable`. 
Per-topic durability is configured with the `enforce_fsync` [topic 
option](/docs/server/topic-options) instead.
+
+## Payloads
+
+Payloads below are the request **body** (the bytes after the 256-byte header). 
Types reference the [shared encodings](/docs/binary-protocol/encodings). Empty 
payload means the body has zero bytes.
+
+### System
+
+**Ping. Code: 1.** Empty payload. The only command accepted before login.
+
+**Get stats. Code: 10.** Empty payload.
+
+**Get snapshot. Code: 11.**
+
+```text
+[compression: u8][types_count: u8][snapshot_type: u8] x types_count
+```
+
+Compression codes: 1 = Stored, 2 = Deflated, 3 = Bzip2, 4 = Zstd, 5 = Lzma, 6 
= Xz. Snapshot type codes: 1 = FilesystemOverview, 2 = ProcessList, 3 = 
ResourceUsage, 4 = Test, 5 = ServerLogs, 6 = ServerConfig, 100 = All.
+
+**Get cluster metadata. Code: 12.** Empty payload.
+
+**Describe options. Code: 13.**
+
+```text
+[scope: u8]
+```
+
+Scope: 1 = topic, 2 = stream, 3 = user. Returns the option catalog for the 
scope: keys, value kinds, defaults, and descriptions. See [Options 
block](/docs/binary-protocol/encodings#options-block).
+
+**Get me. Code: 20.** Empty payload.
+
+**Get client. Code: 21.**
+
+```text
+[client_id: u32]
+```
+
+**Get clients. Code: 22.** Empty payload.
+
+### Streams
+
+**Get stream. Code: 200.**
+
+```text
+[stream_id: Identifier]
+```
+
+**Get streams. Code: 201.** Empty payload.
+
+**Create stream. Code: 202.**
+
+```text
+[name_len: u8][name: N][options block to end]
+```
+
+The stream option catalog is empty today, so the block is normally empty (zero 
bytes).
+
+**Delete stream. Code: 203.**
+
+```text
+[stream_id: Identifier]
+```
+
+**Update stream. Code: 204.**
+
+```text
+[stream_id: Identifier][name_len: u8][name: N][options block to end]
+```
+
+Patch semantics: absent option keys are left unchanged.
+
+**Purge stream. Code: 205.**
+
+```text
+[stream_id: Identifier]
+```
+
+### Topics
+
+**Get topic. Code: 300.**
+
+```text
+[stream_id: Identifier][topic_id: Identifier]
+```
+
+**Get topics. Code: 301.**
+
+```text
+[stream_id: Identifier]
+```
+
+**Create topic. Code: 302.**
+
+```text
+[stream_id: Identifier][partitions_count: u32][name_len: u8][name: N][options 
block to end]
+```
+
+The fixed fields are the shape of the operation: which stream, how many 
partitions, what name. Every topic setting (`compression_algorithm`, 
`message_expiry`, `max_topic_size`, `segment_size`, `enforce_fsync`, 
`messages_required_to_save`, `size_of_messages_required_to_save`, 
`preallocate_segments`) rides the options block. See [Topic 
options](/docs/server/topic-options). `partitions_count` is an argument, not a 
setting: it is consumed at admission and never persisted as an option.
+
+**Delete topic. Code: 303.**
+
+```text
+[stream_id: Identifier][topic_id: Identifier]
+```
+
+**Update topic. Code: 304.**
+
+```text
+[stream_id: Identifier][topic_id: Identifier][name_len: u8][name: N][options 
block to end]
+```
+
+Only `compression_algorithm`, `message_expiry`, and `max_topic_size` are 
updatable. Absent keys are left unchanged.
+
+**Purge topic. Code: 305.**
+
+```text
+[stream_id: Identifier][topic_id: Identifier]
+```
+
+### Partitions
+
+**Create partitions. Code: 402.**
+
+```text
+[stream_id: Identifier][topic_id: Identifier][partitions_count: u32]
+```
+
+**Delete partitions. Code: 403.**
+
+```text
+[stream_id: Identifier][topic_id: Identifier][partitions_count: u32]
+```
+
+### Segments
+
+**Delete segments. Code: 503.**
+
+```text
+[stream_id: Identifier][topic_id: Identifier][partition_id: 
u32][segments_count: u32]
+```
+
+Deletes the `segments_count` oldest sealed segments of the partition.
+
+### Messages
+
+**Poll messages. Code: 100.**
+
+```text
+[consumer: Consumer][stream_id: Identifier][topic_id: Identifier]
+[partition_flag: u8][partition_id: u32]
+[strategy: 9 bytes][count: u32][auto_commit: u8]
+```
+
+`strategy` is a [polling 
strategy](/docs/binary-protocol/encodings#polling-strategy). `count` is the 
requested number of messages. `auto_commit = 1` stores the consumer offset 
server-side as part of the poll.
+
+The response body:
+
+```text
+[partition_id: u32][current_offset: u64][messages_count: u32]
+[batch records to end]
+```
+
+The 16-byte prefix is followed by a stream of [batch 
records](/docs/binary-protocol/messages) served as stored: each record's header 
carries the stamped `base_offset` and `base_timestamp`, and each frame's deltas 
resolve against them. A record may be a server-sliced view of a larger stored 
batch, so the first polled offset is `base_offset + offset_delta` of the first 
frame, not necessarily `base_offset` itself. `current_offset` is the 
partition's newest offset at poll time.
+
+**Send messages. Code: 101.**
+
+```text
+[metadata_length: u32]
+[stream_id: Identifier][topic_id: Identifier][partitioning: Partitioning]
+[messages_count: u32]
+[batch: 256-byte batch header + frames]
+```
+
+`metadata_length` counts the bytes from `stream_id` through `messages_count` 
inclusive, so a reader can skip straight to the batch. The producer leaves 
`partition_id`, `base_offset`, and `base_timestamp` zero in the [batch 
header](/docs/binary-protocol/messages), and the server stamps them. Every 
checksum is producer-computed and verified at admission. The reserved regions 
must be zero.
+
+**Flush unsaved buffer. Code: 102.**
+
+```text
+[stream_id: Identifier][topic_id: Identifier][partition_id: u32][fsync: u8]
+```
+
+Parses, but the server always answers `FeatureUnavailable`: there is no 
on-demand flush primitive. Use the per-topic `enforce_fsync` option for 
durability guarantees.
+
+### Consumer offsets
+
+**Get consumer offset. Code: 120.**
+
+```text
+[consumer: Consumer][stream_id: Identifier][topic_id: Identifier]
+[partition_flag: u8][partition_id: u32]
+```
+
+Response body (20 bytes):
+
+```text
+[partition_id: u32][current_offset: u64][stored_offset: u64]
+```
+
+**Store consumer offset. Code: 121.**
+
+```text
+[consumer: Consumer][stream_id: Identifier][topic_id: Identifier]
+[partition_flag: u8][partition_id: u32][offset: u64][ack: u8]
+```
+
+**Delete consumer offset. Code: 122.**
+
+```text
+[consumer: Consumer][stream_id: Identifier][topic_id: Identifier]
+[partition_flag: u8][partition_id: u32][ack: u8]
+```
+
+The trailing `ack` byte is mandatory on both write commands. A payload without 
it fails to decode. Ack levels:
+
+| Value | Level | Meaning |
+|-------|-------|---------|
+| 0 | `NoAck` | Leader-local write; respond as soon as local state is updated 
(the fast path `PollMessages` auto-commit uses) |
+| 1 | `Quorum` | Replicate through partition consensus; respond after a quorum 
commit (default for explicit writes) |
+
+### Consumer groups
+
+**Get consumer group. Code: 600.**
+
+```text
+[stream_id: Identifier][topic_id: Identifier][group_id: Identifier]
+```
+
+**Get consumer groups. Code: 601.**
+
+```text
+[stream_id: Identifier][topic_id: Identifier]
+```
+
+**Create consumer group. Code: 602.**
+
+```text
+[stream_id: Identifier][topic_id: Identifier][name_len: u8][name: N]
+```
+
+**Delete consumer group. Code: 603.**
+
+```text
+[stream_id: Identifier][topic_id: Identifier][group_id: Identifier]
+```
+
+**Join consumer group. Code: 604.**
+
+```text
+[stream_id: Identifier][topic_id: Identifier][group_id: Identifier]
+```
+
+**Leave consumer group. Code: 605.**
+
+```text
+[stream_id: Identifier][topic_id: Identifier][group_id: Identifier]
+```
+
+**Sync consumer group. Code: 606.**
+
+```text
+[stream_id: Identifier][topic_id: Identifier][group_id: Identifier]
+```
+
+Read-only: a member asks for its current partition assignment and the group 
generation, so it can select partitions to poll client-side.
+
+### Users
+
+**Get user. Code: 31.**
+
+```text
+[user_id: Identifier]
+```
+
+**Get users. Code: 32.** Empty payload.
+
+**Create user. Code: 33.**
+
+```text
+[username_len: u8][username: N][password_len: u8][password: N][status: u8]
+[has_permissions: u8][permissions_len: u32, only when has_permissions = 
1][permissions: M bytes]
+[options block to end]
+```
+
+`permissions_len` and the permissions bytes are present **only** when 
`has_permissions = 1`. When it is 0 the options block follows immediately. The 
user option catalog is empty today, so the block is normally empty. `status`: 1 
= active, 2 = inactive.
+
+**Delete user. Code: 34.**
+
+```text
+[user_id: Identifier]
+```
+
+**Update user. Code: 35.**
+
+```text
+[user_id: Identifier]
+[has_username: u8][username_len: u8, only when 1][username: N, only when 1]
+[has_status: u8][status: u8, only when 1]
+[options block to end]
+```
+
+**Update permissions. Code: 36.**
+
+```text
+[user_id: Identifier][has_permissions: u8]
+[permissions_len: u32, only when has_permissions = 1][permissions: M bytes, 
only when 1]
+```
+
+As in `CreateUser`, the length field is conditional: with `has_permissions = 
0` the payload ends at the flag.
+
+**Change password. Code: 37.**
+
+```text
+[user_id: Identifier]
+[current_password_len: u8][current_password: N]
+[new_password_len: u8][new_password: N]
+```
+
+### Authentication
+
+Login and logout are part of the [connection 
lifecycle](/docs/binary-protocol/connection-lifecycle):
+
+- **Login register. Code: 40.** `Operation::Register`. The body is 
`ClientVersionInfo` + credentials, described in 
[Login-register](/docs/binary-protocol/connection-lifecycle#login-register).
+- **Login register with PAT. Code: 45.** `Operation::Register`. The body is 
`ClientVersionInfo` + token.
+- **Logout user. Code: 39.** `Operation::Logout`. Empty payload.
+- Codes **38** and **44** are the pre-register login shapes. The server 
rejects both with a `MalformedLogin` eviction.
+
+### Personal access tokens
+
+**Get personal access tokens. Code: 41.** Empty payload.
+
+**Create personal access token. Code: 42.**
+
+```text
+[name_len: u8][name: N][expiry: u64]
+```
+
+`expiry` is a duration in **microseconds**. Two sentinel values exist: `0` 
(server default, which currently means no expiry) and `u64::MAX` (never 
expires).
+
+**Delete personal access token. Code: 43.**
+
+```text
+[name_len: u8][name: N]
+```
diff --git a/content/docs/binary-protocol/connection-lifecycle.mdx 
b/content/docs/binary-protocol/connection-lifecycle.mdx
new file mode 100644
index 00000000..3c6e49d7
--- /dev/null
+++ b/content/docs/binary-protocol/connection-lifecycle.mdx
@@ -0,0 +1,65 @@
+---
+title: Connection lifecycle
+---
+
+## Protocol version
+
+The protocol version is a semver packed into one u32, 10 bits per component 
(each must be below 1024):
+
+```text
+bits 31..30  reserved (zero)
+bits 29..20  major
+bits 19..10  minor
+bits  9..0   patch
+value = major << 20 | minor << 10 | patch
+```
+
+Integer order equals semver order. The value tracks the `iggy_binary_protocol` 
crate release. Under 0.x the compatibility gate is minor-scoped: the server 
accepts a client whose packed version is at least the server's minimum and 
whose `major.minor` is at most the server's. Patch releases never change the 
wire, so the upper bound ignores patch. Past 1.0.0 the gate follows strict 
semver (major bump = incompatible).
+
+## Login-register
+
+The only way to authenticate is the register handshake: command code **40** 
(`LOGIN_REGISTER`, username and password) or **45** (`LOGIN_REGISTER_WITH_PAT`, 
personal access token). Both ride `operation = 1` (`Register`) with `session = 
0` and a freshly minted non-zero `client` id.
+
+Both request bodies begin with the `ClientVersionInfo` prefix, so the server 
can gate on the version before touching credentials:
+
+```text
+[protocol_version: u32]
+[sdk_name_len: u8][sdk_name: UTF-8, 1-255 bytes]
+[sdk_version_len: u8][sdk_version: UTF-8, 1-255 bytes]
+```
+
+`protocol_version` is the packed version of the protocol the client was built 
against. `sdk_name` identifies the SDK (for example `rust-sdk`, `go-sdk`). 
`sdk_version` is the SDK's own build version.
+
+After the prefix:
+
+**`LOGIN_REGISTER` (code 40):**
+
+```text
+[ClientVersionInfo]
+[username_len: u8][username: N]
+[password_len: u8][password: N]
+[context_len: u32][context: N, only when context_len > 0]
+```
+
+**`LOGIN_REGISTER_WITH_PAT` (code 45):**
+
+```text
+[ClientVersionInfo]
+[token_len: u8][token: N]
+[context_len: u32][context: N, only when context_len > 0]
+```
+
+An incompatible protocol version is answered with a 256-byte `Eviction` frame, 
reason `14` (`IncompatibleProtocol`), carrying the accepted window at bytes 144 
(max) and 148 (min) as packed u32 versions. A body without a decodable 
`ClientVersionInfo` prefix gets reason `15` (`MalformedLogin`) with a zero 
window. Bad credentials get reasons 9-11. See 
[EvictionHeader](/docs/binary-protocol/framing#evictionheader) for the frame 
layout.
+
+A successful login is a normal `Reply` whose body is:
+
+```text
+[user_id: u32]
+[session: u64]
+[server_protocol_version: u32]
+[server_version_len: u8][server_version: N]
+```
+
+The client stores `session` and echoes it in every subsequent request header. 
Logout is command code **39** (`LOGOUT_USER`), `operation = 3` (`Logout`), 
empty body.
+
+The legacy login commands **38** (`LOGIN_USER`) and **44** 
(`LOGIN_WITH_PERSONAL_ACCESS_TOKEN`) are refused: the server answers them with 
an `Eviction` frame, reason `15` (`MalformedLogin`). Only `PING` is accepted 
before login. Every other command on an unauthenticated connection is denied.
diff --git a/content/docs/binary-protocol/encodings.mdx 
b/content/docs/binary-protocol/encodings.mdx
new file mode 100644
index 00000000..aa55d3de
--- /dev/null
+++ b/content/docs/binary-protocol/encodings.mdx
@@ -0,0 +1,127 @@
+---
+title: Shared encodings
+---
+
+Primitives the [command payloads](/docs/binary-protocol/commands) are built 
from.
+
+## Name
+
+Length-prefixed UTF-8 string, used for stream, topic, group, user, and token 
names:
+
+```text
+[length: u8][value: UTF-8, 1-255 bytes]
+```
+
+## Identifier
+
+Polymorphic identifier of a stream, topic, user, or consumer group:
+
+```text
+[kind: u8][length: u8][value: N bytes]
+```
+
+- Numeric: `kind = 1`, `length = 4`, value is a u32.
+- String: `kind = 2`, `length = 1..255`, value is UTF-8 bytes.
+
+## Consumer
+
+Identifies a consumer or a consumer group in message and offset commands:
+
+```text
+[kind: u8][id: Identifier]
+```
+
+- `kind = 1`: consumer.
+- `kind = 2`: consumer group.
+
+## Optional partition id
+
+Several commands take an optional partition id encoded as 5 fixed bytes:
+
+```text
+[flag: u8][partition_id: u32]
+```
+
+`flag = 1` means the id is set. `flag = 0` means none (the value bytes are 
then zero). For consumer-group consumers the partition id is left unset and the 
server resolves it.
+
+## Partitioning
+
+How `SendMessages` picks the target partition:
+
+```text
+[kind: u8][length: u8][value: 0-255 bytes]
+```
+
+- `Balanced`: `kind = 1`, `length = 0`, no value. The server resolves the 
partition at admission with a per-node round-robin counter.
+- `PartitionId`: `kind = 2`, `length = 4`, value is a u32 partition id.
+- `MessagesKey`: `kind = 3`, `length = 1..255`, value is a routing key hashed 
to a partition.
+
+First-party binary SDKs pre-resolve balanced and key routing client-side and 
normally send `kind = 2`.
+
+## Polling strategy
+
+Fixed 9 bytes:
+
+```text
+[kind: u8][value: u64]
+```
+
+| Kind | Name | Meaning of `value` |
+|------|------|--------------------|
+| 1 | Offset | Start at this offset |
+| 2 | Timestamp | Start at this timestamp (microseconds) |
+| 3 | First | Start from the beginning (`value` ignored) |
+| 4 | Last | Start from the end (`value` ignored) |
+| 5 | Next | Continue after the stored consumer offset (`value` ignored) |
+
+## Header kinds
+
+Typed values used by user headers and option values. The kind byte:
+
+| Code | Kind | Code | Kind |
+|------|------|------|------|
+| 1 | Raw | 9 | Uint8 |
+| 2 | String | 10 | Uint16 |
+| 3 | Bool | 11 | Uint32 |
+| 4 | Int8 | 12 | Uint64 |
+| 5 | Int16 | 13 | Uint128 |
+| 6 | Int32 | 14 | Float32 |
+| 7 | Int64 | 15 | Float64 |
+| 8 | Int128 | | |
+
+Integers are little-endian. `Bool` is one byte.
+
+## User headers block
+
+A user headers block (inside a [message 
frame](/docs/binary-protocol/messages)) is a flat run of TLV fields:
+
+```text
+[kind: u8][length: u32][data: length bytes]
+```
+
+Fields pair up: first the key, then the value. Keys are `String` kind. Every 
`length` must be 1..=255, kind 0 is rejected, and the block must consume its 
byte range exactly. Unknown value kind codes are preserved and forwarded, so 
headers survive mixed-version clusters.
+
+## Options block
+
+`CreateStream`, `UpdateStream`, `CreateTopic`, `UpdateTopic`, `CreateUser`, 
and `UpdateUser` end with a key-value **options block** that reuses the 
user-headers TLV encoding. The block runs from its start to the end of the 
payload (no length prefix, and the validator requires exact consumption) and 
may be empty.
+
+On top of the TLV walk, options enforce: string keys only (kind 2, valid 
UTF-8), no duplicate keys, at most 1024 entries, and at most 100000 bytes total.
+
+Semantics:
+
+- **Create** requests resolve absent keys to server defaults and persist the 
effective values, so `GetTopic` always shows what is in force.
+- **Update** requests are patches: keys absent from the block are left alone, 
never reset. A client built before a key existed cannot erase it.
+- Unknown keys are rejected at the wire edge, never silently skipped.
+
+The catalog is discoverable at runtime with `DESCRIBE_OPTIONS` (code 13), 
payload `[scope: u8]` with scope `1` = topic, `2` = stream, `3` = user (HTTP: 
`GET /options/topic`). Today only topics have keys. The stream and user 
catalogs are empty, and any key sent for them is rejected. The topic catalog 
(`segment_size`, `enforce_fsync`, `message_expiry`, `max_topic_size`, and the 
rest) with defaults and constraints is documented on the [Topic 
options](/docs/server/topic-options) page. `Updat [...]
+
+## Compression
+
+The `compression_algorithm` value used by topic options:
+
+| Code / name | Algorithm |
+|-------------|-----------|
+| 1, `none` | No compression (default) |
+| 2, `gzip` | Gzip |
+
+Any other value is rejected. The option is a **placeholder today**: the value 
is validated, persisted, and echoed back, but neither the server nor the SDKs 
compress or decompress payloads yet. To compress today, do it client-side and 
tag messages via user headers - see the [message headers 
examples](https://github.com/apache/iggy/tree/master/examples/rust/src/message-headers)
 in the Iggy repo.
diff --git a/content/docs/binary-protocol/framing.mdx 
b/content/docs/binary-protocol/framing.mdx
new file mode 100644
index 00000000..ec0fc080
--- /dev/null
+++ b/content/docs/binary-protocol/framing.mdx
@@ -0,0 +1,194 @@
+---
+title: Framing
+---
+
+Every message on a binary transport is a fixed **256-byte header** followed by 
an optional body. The header is a `#[repr(C)]` struct decoded by pointer cast 
(zero-copy), so field offsets are fixed and enforced at compile time in the 
server. Three header shapes cross the client boundary:
+
+- `RequestHeader`: client to server.
+- `ReplyHeader`: server to client, answers one request.
+- `EvictionHeader`: server to client, session-terminal rejection with no body.
+
+The header shape is identified by the `command` byte at offset 60. Values a 
client sends or receives:
+
+| `command` | Value | Direction | Meaning |
+|-----------|-------|-----------|---------|
+| `Request` | 5 | client to server | A command request |
+| `Reply` | 8 | server to client | Answer to one request |
+| `Eviction` | 13 | server to client | Session is dead, no per-request 
correlation |
+
+Other `command` values (Prepare, PrepareOk, view-change traffic, and so on) 
are replica-to-replica consensus messages and never appear on a client 
connection. They are documented on the 
[Server-to-server](/docs/binary-protocol/cluster) page.
+
+The `size` field at offset 48 is the total frame length in bytes, header 
included. A body, when present, immediately follows the 256 header bytes and 
has length `size - 256`. Readers can decode `size` before typing the header. 
That offset is a protocol constant.
+
+## RequestHeader
+
+Client to server. 256 bytes.
+
+| Offset | Size | Field | Type | Description |
+|--------|------|-------|------|-------------|
+| 0 | 16 | `checksum` | u128 | Frame seal. Not used on client-facing frames; 
send zero. |
+| 16 | 16 | `checksum_body` | u128 | Body seal. Not used on client-facing 
frames; send zero. |
+| 32 | 16 | `cluster` | u128 | Cluster id. Send zero. |
+| 48 | 4 | `size` | u32 | Total frame length: 256 + body length. |
+| 52 | 4 | `view` | u32 | Consensus view. Send zero. |
+| 56 | 4 | `release` | u32 | Must be zero. |
+| 60 | 1 | `command` | u8 | `5` (`Request`). |
+| 61 | 1 | `replica` | u8 | Send zero. |
+| 62 | 66 | reserved | bytes | Zero. |
+| 128 | 16 | `client` | u128 | Client-chosen session identity, non-zero. 
Minted fresh for each registration. |
+| 144 | 16 | `request_checksum` | u128 | Optional integrity stamp over the 
request body (the Rust SDK uses XxHash3-64 widened to u128). Lets the server's 
client table catch a `request` number reused for different arguments. Zero 
disables the comparison. Stamped only for metadata-plane operations; zero for 
partition-plane and non-replicated ones. |
+| 160 | 8 | `timestamp` | u64 | Informational; the server echoes it into 
`ReplyHeader.timestamp`. May be zero. |
+| 168 | 8 | `request` | u64 | Request number, per client. See [request 
numbering](#request-numbering). |
+| 176 | 1 | `operation` | u8 | The [`Operation`](#operation-discriminants) 
discriminant. |
+| 177 | 7 | padding | bytes | Zero. |
+| 184 | 8 | `session` | u64 | Session fence epoch from the login reply. Zero 
on the login-register request itself and on non-replicated operations sent 
before login (only `PING` is accepted unauthenticated). |
+| 192 | 4 | `user_id` | u32 | Ignored on the wire; the server stamps the 
authenticated user itself. Send zero. |
+| 196 | 60 | reserved | bytes | Zero, except: for `operation = 2` 
(`NonReplicated`) bytes 196..200 carry the u32 command code, little-endian. |
+
+## Operation discriminants
+
+The `operation` byte tells the server which state-machine operation the 
request carries. Replicated operations are identified by `operation` alone. 
Non-replicated operations (reads, ping) all use `operation = 2` and carry their 
concrete u32 command code in header bytes 196..200.
+
+Values a client may send:
+
+| Value | Operation | Plane |
+|-------|-----------|-------|
+| 1 | `Register` | session (login handshake, codes 40 and 45) |
+| 2 | `NonReplicated` | reads and ping; the u32 code rides bytes 196..200 |
+| 3 | `Logout` | session (code 39) |
+| 128 | `CreateStream` | metadata |
+| 129 | `UpdateStream` | metadata |
+| 130 | `DeleteStream` | metadata |
+| 131 | `PurgeStream` | metadata |
+| 132 | `CreateTopic` | metadata |
+| 133 | `UpdateTopic` | metadata |
+| 134 | `DeleteTopic` | metadata |
+| 135 | `PurgeTopic` | metadata |
+| 136 | `CreatePartitions` | metadata |
+| 137 | `DeletePartitions` | metadata |
+| 138 | `DeleteSegments` | metadata (resolved to an internal partition 
truncation) |
+| 139 | `CreateConsumerGroup` | metadata |
+| 140 | `DeleteConsumerGroup` | metadata |
+| 141 | `CreateUser` | metadata |
+| 142 | `UpdateUser` | metadata |
+| 143 | `DeleteUser` | metadata |
+| 144 | `ChangePassword` | metadata |
+| 145 | `UpdatePermissions` | metadata |
+| 146 | `CreatePersonalAccessToken` | metadata |
+| 147 | `DeletePersonalAccessToken` | metadata |
+| 148 | `JoinConsumerGroup` | metadata |
+| 149 | `LeaveConsumerGroup` | metadata |
+| 160 | `SendMessages` | partition |
+| 161 | `StoreConsumerOffset` | partition |
+| 162 | `DeleteConsumerOffset` | partition |
+
+Value 0 is reserved and rejected. The 64..127 range is reserved for 
server-internal operations (currently 64 through 68 are assigned) and every 
value in it is refused from clients.
+
+The planes matter for delivery semantics:
+
+- **Metadata operations** replicate through the metadata consensus group. The 
server deduplicates them by `(client, request)` and caches replies, so a 
retried request gets the cached answer instead of a double apply (exactly-once).
+- **Partition operations** replicate through their partition's consensus 
group. They are at-least-once: no reply cache, a replay may apply again.
+- **Non-replicated operations** are reads. They bypass consensus and 
deduplication entirely.
+
+## Request numbering
+
+`request` is a per-client counter the server's client table tracks for 
metadata-plane operations:
+
+- Metadata operations must send a strictly increasing `request` (the SDK 
advances the counter per metadata request).
+- Partition operations and non-replicated operations send the current counter 
value without advancing it. The server doesn't track theirs.
+- The counter is a watermark, not a contiguous sequence: any value above the 
last accepted one is admissible.
+
+`session` is the fence epoch: the value handed back by the login reply. Every 
request after login must echo it. When the same `client` id registers again, 
the new registration mints a higher epoch and requests carrying the old one are 
fenced (rejected as zombies).
+
+## ReplyHeader
+
+Server to client. 256 bytes, followed by `size - 256` bytes of body.
+
+| Offset | Size | Field | Type | Description |
+|--------|------|-------|------|-------------|
+| 0 | 16 | `checksum` | u128 | Zero on client-facing frames. |
+| 16 | 16 | `checksum_body` | u128 | Zero on client-facing frames. |
+| 32 | 16 | `cluster` | u128 | Cluster id. |
+| 48 | 4 | `size` | u32 | Total frame length: 256 + body length. |
+| 52 | 4 | `view` | u32 | Consensus view the reply was produced in. |
+| 56 | 4 | `release` | u32 | Zero. |
+| 60 | 1 | `command` | u8 | `8` (`Reply`). |
+| 61 | 1 | `replica` | u8 | Answering replica index. |
+| 62 | 66 | reserved | bytes | Zero. |
+| 128 | 16 | `request_checksum` | u128 | Echoed from the request. |
+| 144 | 16 | `context` | u128 | Server context. |
+| 160 | 16 | `client` | u128 | Echoed client id. |
+| 176 | 8 | `op` | u64 | Log position of the committed operation. |
+| 184 | 8 | `commit` | u64 | Commit point at reply time. |
+| 192 | 8 | `timestamp` | u64 | Echo of the request `timestamp`. |
+| 200 | 8 | `request` | u64 | Echoed request number; correlate replies by 
this. |
+| 208 | 1 | `operation` | u8 | Echoed operation discriminant. |
+| 209 | 7 | padding | bytes | Zero. |
+| 216 | 4 | `status` | u32 | `0` = accepted. Nonzero = an `IggyError` code for 
a failure decided **before** commit (authorization denial, admission reject). A 
nonzero status always comes with an empty body. |
+| 220 | 36 | reserved | bytes | Zero. |
+
+Decode order for a client:
+
+1. Read 256 bytes, check `command`. `13` (`Eviction`) means the session is 
dead: map the eviction reason to an error and stop. `8` (`Reply`) continues.
+2. Read the remaining `size - 256` body bytes.
+3. If `status` is nonzero, the request failed pre-commit. The status value is 
the `IggyError` code and the body is empty.
+4. Otherwise decode the body. For result-framed operations, strip the [result 
section](#result-section) first.
+
+## Result section
+
+Replies for all metadata operations and for the partition-plane 
consumer-offset writes (`StoreConsumerOffset`, `DeleteConsumerOffset`) are 
**result-framed**: the body starts with a committed-result section ahead of the 
typed payload.
+
+```text
+[count: u32]
+count x { index: u32, result: u32 }
+```
+
+- Success: `count = 0`, and the typed response payload (if any) follows the 4 
count bytes.
+- Committed business rejection: one entry `{ index: 0, result: error_code }` 
and no payload.
+
+The header `status` channel and the result section are mutually exclusive by 
construction: a reply either failed pre-commit (`status` nonzero, empty body) 
or committed (`status` zero, result section present). A login-register reply 
carries the result section only when non-empty. On success its body starts 
directly with the [login response 
payload](/docs/binary-protocol/connection-lifecycle#login-register).
+
+Replies to non-replicated commands aren't result-framed: after `status = 0` 
the body is the response payload directly.
+
+## EvictionHeader
+
+Server to client. 256 bytes, never a body. An eviction is session-terminal: it 
says "this session is dead", carries no per-request correlation, and a client 
should deinitialize and re-login.
+
+| Offset | Size | Field | Type | Description |
+|--------|------|-------|------|-------------|
+| 0 | 16 | `checksum` | u128 | Zero on client-facing frames. |
+| 16 | 16 | `checksum_body` | u128 | Zero on client-facing frames. |
+| 32 | 16 | `cluster` | u128 | Cluster id. |
+| 48 | 4 | `size` | u32 | Always 256: an eviction has no body. |
+| 52 | 4 | `view` | u32 | |
+| 56 | 4 | `release` | u32 | Zero. |
+| 60 | 1 | `command` | u8 | `13` (`Eviction`). |
+| 61 | 1 | `replica` | u8 | |
+| 62 | 66 | reserved | bytes | Zero. |
+| 128 | 16 | `client` | u128 | The evicted client id. |
+| 144 | 4 | `server_protocol_version` | u32 | Accepted protocol window (max), 
packed semver. Set only for reason 14, zero otherwise. |
+| 148 | 4 | `server_protocol_version_min` | u32 | Accepted protocol window 
(min). Set only for reason 14, zero otherwise. |
+| 152 | 103 | reserved | bytes | Zero. |
+| 255 | 1 | `reason` | u8 | Eviction reason, see below. |
+
+Eviction reasons:
+
+| Value | Reason | Meaning |
+|-------|--------|---------|
+| 1 | `NoSession` | No session exists for this client id |
+| 2 | `ClientReleaseTooLow` | Client release below cluster minimum |
+| 3 | `ClientReleaseTooHigh` | Client release above cluster maximum |
+| 4 | `InvalidRequestOperation` | Unknown `operation` discriminant |
+| 5 | `InvalidRequestBody` | Body failed validation |
+| 6 | `InvalidRequestBodySize` | Body size mismatch |
+| 7 | `SessionTooLow` | Session epoch below the cluster's retained minimum |
+| 8 | `SessionReleaseMismatch` | Session bound to a different release |
+| 9 | `InvalidCredentials` | Login refused: bad username or password |
+| 10 | `InvalidToken` | Login refused: bad personal access token |
+| 11 | `UserInactive` | Login refused: user inactive |
+| 12 | `SessionError` | Session-level failure |
+| 13 | `StaleClient` | Missed heartbeats; server evicted the session |
+| 14 | `IncompatibleProtocol` | Client protocol version outside the accepted 
window (see bytes 144..152) |
+| 15 | `MalformedLogin` | Login body without a decodable version prefix, or a 
legacy login code |
+
+Value 0 is reserved and never sent.
diff --git a/content/docs/binary-protocol/index.mdx 
b/content/docs/binary-protocol/index.mdx
new file mode 100644
index 00000000..d37057f8
--- /dev/null
+++ b/content/docs/binary-protocol/index.mdx
@@ -0,0 +1,20 @@
+---
+title: Binary Protocol
+---
+
+Iggy speaks one binary protocol over its three binary transports: TCP, QUIC, 
and WebSocket. Commands, responses, data models, and status codes are the same 
on all of them. The HTTP transport is separate: it exposes the same operations 
as JSON REST endpoints, listed in 
[server.http](https://github.com/apache/iggy/blob/master/core/server/server.http).
+
+This section describes **binary protocol version 0.11.0**. The protocol 
version is the semver of the `iggy_binary_protocol` crate, and it's exchanged 
and checked during login (see [Connection 
lifecycle](/docs/binary-protocol/connection-lifecycle)). The crate itself is 
the source of truth: every request and response module carries a `Wire format:` 
doc comment, and these pages are written against those.
+
+All multi-byte integers are **little-endian** unless stated otherwise.
+
+The same 256-byte header layout carries two traffic planes. The pages below 
cover both:
+
+- **Server ↔ SDK client**, everything a client sends or receives:
+  - [Framing](/docs/binary-protocol/framing): the 256-byte headers every 
client-facing message rides in.
+  - [Connection lifecycle](/docs/binary-protocol/connection-lifecycle): 
protocol versioning and the login-register handshake.
+  - [Commands](/docs/binary-protocol/commands): the command codes and 
per-command payload layouts.
+  - [Shared encodings](/docs/binary-protocol/encodings): primitives the 
payloads are built from.
+  - [Message batches](/docs/binary-protocol/messages): the one layout messages 
travel and rest in.
+- **Server ↔ server**, the replica-to-replica consensus traffic that never 
appears on a client connection:
+  - [Server-to-server](/docs/binary-protocol/cluster): prepare/ack 
replication, view changes, and state transfer.
diff --git a/content/docs/binary-protocol/messages.mdx 
b/content/docs/binary-protocol/messages.mdx
new file mode 100644
index 00000000..12634a88
--- /dev/null
+++ b/content/docs/binary-protocol/messages.mdx
@@ -0,0 +1,41 @@
+---
+title: Message batches
+---
+
+Messages travel and rest in exactly one layout, the **batch**: the 
`SendMessages` body, the replicated record, the persisted segment record, and 
the poll reply all share it. There is no other message encoding.
+
+```text
+[batch header: 256 bytes][blob: message frames]
+frame = [frame header: 48 bytes][payload][user_headers]
+```
+
+## Batch header (256 bytes)
+
+| Offset | Size | Field | Type | Description |
+|--------|------|-------|------|-------------|
+| 0 | 8 | `partition_id` | u64 | Server-stamped at admission. Producers send 
zero. |
+| 8 | 8 | `base_offset` | u64 | Server-stamped when the batch is appended to 
the partition journal. Producers send zero. |
+| 16 | 8 | `base_timestamp` | u64 | Server-stamped at journal append 
(microseconds). Producers send zero. |
+| 24 | 8 | `origin_timestamp` | u64 | Producer-set: the earliest 
`origin_timestamp` among the batch's messages (microseconds). |
+| 32 | 8 | `batch_length` | u64 | Total batch size: 256 + blob length. |
+| 40 | 8 | `batch_checksum` | u64 | XxHash3-64 over the header fields 
(`partition_id`, `base_offset`, `base_timestamp`, `origin_timestamp`, 
`batch_length`, `message_count`, in that order, little-endian bytes) followed 
by each frame's 8 checksum bytes. Recomputed by the server when it stamps the 
header. |
+| 48 | 4 | `message_count` | u32 | Number of frames in the blob. |
+| 52 | 204 | reserved | bytes | Must be zero; the server rejects a batch with 
nonzero reserved bytes. |
+
+## Message frame header (48 bytes)
+
+| Offset | Size | Field | Type | Description |
+|--------|------|-------|------|-------------|
+| 0 | 8 | `checksum` | u64 | XxHash3-64 over frame bytes 8..48 followed by 
`payload` and `user_headers`. Producer-computed; verified at admission. |
+| 8 | 16 | `id` | u128 | Message id (for example a UUID). |
+| 24 | 4 | `offset_delta` | u32 | Message offset = `base_offset + 
offset_delta`. Producer sends the frame's index in the batch. |
+| 28 | 4 | `timestamp_delta` | u32 | Microsecond delta against the batch 
`origin_timestamp`. One batch spans at most about 71.6 minutes of producer 
clock (`u32` microseconds). |
+| 32 | 4 | `user_headers_length` | u32 | Byte length of the user headers 
block. |
+| 36 | 4 | `payload_length` | u32 | Byte length of the payload. |
+| 40 | 8 | reserved | u64 | Must be zero. |
+
+After the 48 header bytes: `payload` (`payload_length` bytes), then 
`user_headers` (`user_headers_length` bytes). The payload comes **first**.
+
+A frame's absolute position is derived, not stored: offset = `base_offset + 
offset_delta`. The server-stamped time is the flat batch `base_timestamp` (the 
delta does not apply to it); producer time resolves as `origin_timestamp + 
timestamp_delta`.
+
+The 64-byte `IggyMessageHeader` (checksum, id, offset, timestamp, 
origin_timestamp, lengths, reserved) that earlier protocol generations put in 
front of every message **no longer exists on the wire or on disk**. It survives 
only as an in-memory model.
diff --git a/content/docs/binary-protocol/meta.json 
b/content/docs/binary-protocol/meta.json
new file mode 100644
index 00000000..45e3f6c9
--- /dev/null
+++ b/content/docs/binary-protocol/meta.json
@@ -0,0 +1,4 @@
+{
+  "title": "Binary Protocol",
+  "pages": ["index", "framing", "connection-lifecycle", "commands", 
"encodings", "messages", "cluster"]
+}
diff --git a/content/docs/introduction/concepts.mdx 
b/content/docs/introduction/concepts.mdx
index c0d74223..47ff815f 100644
--- a/content/docs/introduction/concepts.mdx
+++ b/content/docs/introduction/concepts.mdx
@@ -35,7 +35,7 @@ The stream is a logical concept, and you might think of it as 
a **namespace**. F
 
 The topic is also the logical concept, which is a part of the stream. The 
topic is identified by its unique ID. You could think of topic as an entity 
being responsible for storing the specific type of the records. For example, 
you could have a topic for the user events, and another topic for the order 
events, etc.
 
-The messages are not being stored in the topic directly, but rather in the 
**partitions**, which are assigned to the topic. The topic can have one or more 
partitions assigned, that could help achieve higher parallelism and throughput. 
The topic can also have the **retention policy** assigned, which means that the 
records are being deleted automatically once they are older than the specified 
retention period. Topics also support configurable compression (`none`, 
`gzip`), maximum size limi [...]
+The messages are not being stored in the topic directly, but rather in the 
**partitions**, which are assigned to the topic. The topic can have one or more 
partitions assigned, that could help achieve higher parallelism and throughput. 
The topic can also have the **retention policy** assigned, which means that the 
records are being deleted automatically once they are older than the specified 
retention period. Topics also support maximum size limits and per-topic 
durability options (`segme [...]
 
 ## Partition
 
@@ -74,7 +74,7 @@ Consumer groups provide horizontal scaling for message 
consumption. When multipl
 
 <MessageHeaderDiagram />
 
-In the SDKs, each message carries a 64-byte in-memory header (little-endian 
fields). On the wire and on disk, messages travel inside batch records with a 
compact 48-byte per-message frame. See the [binary schema](/docs/server/schema) 
for the exact encodings.
+In the SDKs, each message carries a 64-byte in-memory header (little-endian 
fields). On the wire and on disk, messages travel inside batch records with a 
compact 48-byte per-message frame. See the [Binary 
Protocol](/docs/binary-protocol) section for the exact encodings.
 
 | Field | Bytes | Type | Description |
 |-------|-------|------|-------------|
diff --git a/content/docs/meta.json b/content/docs/meta.json
index 4737886a..3c2bc42b 100644
--- a/content/docs/meta.json
+++ b/content/docs/meta.json
@@ -5,6 +5,7 @@
     "introduction",
     "server",
     "clustering",
+    "binary-protocol",
     "connectors",
     "sdk",
     "cli",
diff --git a/content/docs/server/meta.json b/content/docs/server/meta.json
index 3d3ec62d..4c9c3bd6 100644
--- a/content/docs/server/meta.json
+++ b/content/docs/server/meta.json
@@ -1,4 +1,4 @@
 {
   "title": "Server",
-  "pages": ["introduction", "configuration", "topic-options", 
"storage-engine", "networking", "security", "docker", "schema", "benchmarking"]
+  "pages": ["introduction", "configuration", "topic-options", 
"storage-engine", "networking", "security", "docker", "benchmarking"]
 }
diff --git a/content/docs/server/networking.mdx 
b/content/docs/server/networking.mdx
index a136e68e..7948f9fd 100644
--- a/content/docs/server/networking.mdx
+++ b/content/docs/server/networking.mdx
@@ -17,7 +17,7 @@ Iggy supports four transport protocols simultaneously, each 
optimized for differ
 
 ## Binary protocol
 
-Every request and reply on the stateful transports starts with a fixed 
256-byte header (the same Viewstamped Replication header layout the cluster 
uses internally), followed by the command payload. The `iggy_binary_protocol` 
crate provides zero-copy serialization for these headers, and foreign SDKs 
implement the same contract natively. See the [protocol 
schema](/docs/server/schema) for the full specification.
+Every request and reply on the stateful transports starts with a fixed 
256-byte header (the same Viewstamped Replication header layout the cluster 
uses internally), followed by the command payload. The `iggy_binary_protocol` 
crate provides zero-copy serialization for these headers, and foreign SDKs 
implement the same contract natively. See the [Binary 
Protocol](/docs/binary-protocol) section for the full specification.
 
 ## Connection handling across shards
 
diff --git a/content/docs/server/schema.mdx b/content/docs/server/schema.mdx
deleted file mode 100644
index 83a3ebac..00000000
--- a/content/docs/server/schema.mdx
+++ /dev/null
@@ -1,857 +0,0 @@
----
-title: Schema
----
-
-Iggy speaks one binary protocol over its three binary transports: TCP, QUIC, 
and WebSocket. Commands, responses, data models, and status codes are the same 
on all of them. The HTTP transport is separate: it exposes the same operations 
as JSON REST endpoints, listed in 
[server.http](https://github.com/apache/iggy/blob/master/core/server/server.http).
-
-This page describes **binary protocol version 0.11.0**. The protocol version 
is the semver of the `iggy_binary_protocol` crate, and it's exchanged and 
checked during login (see [Connection lifecycle](#connection-lifecycle)). The 
crate itself is the source of truth: every request and response module carries 
a `Wire format:` doc comment, and this page is generated against those.
-
-All multi-byte integers are **little-endian** unless stated otherwise.
-
-The page is organized in three layers:
-
-1. [Framing](#framing): the 256-byte consensus headers every message rides in.
-2. [Command catalog](#command-catalog): the command codes and how each maps 
onto the framing.
-3. [Payload encodings](#shared-encodings): shared primitives, the message 
batch format, and per-command payload layouts.
-
-## Framing
-
-Every message on a binary transport is a fixed **256-byte header** followed by 
an optional body. The header is a `#[repr(C)]` struct decoded by pointer cast 
(zero-copy), so field offsets are fixed and enforced at compile time in the 
server. Three header shapes cross the client boundary:
-
-- `RequestHeader`: client to server.
-- `ReplyHeader`: server to client, answers one request.
-- `EvictionHeader`: server to client, session-terminal rejection with no body.
-
-The header shape is identified by the `command` byte at offset 60. Values a 
client sends or receives:
-
-| `command` | Value | Direction | Meaning |
-|-----------|-------|-----------|---------|
-| `Request` | 5 | client to server | A command request |
-| `Reply` | 8 | server to client | Answer to one request |
-| `Eviction` | 13 | server to client | Session is dead, no per-request 
correlation |
-
-Other `command` values (Prepare, PrepareOk, view-change traffic, and so on) 
are replica-to-replica consensus messages and never appear on a client 
connection.
-
-The `size` field at offset 48 is the total frame length in bytes, header 
included. A body, when present, immediately follows the 256 header bytes and 
has length `size - 256`. Readers can decode `size` before typing the header. 
That offset is a protocol constant.
-
-### RequestHeader
-
-Client to server. 256 bytes.
-
-| Offset | Size | Field | Type | Description |
-|--------|------|-------|------|-------------|
-| 0 | 16 | `checksum` | u128 | Frame seal. Not used on client-facing frames; 
send zero. |
-| 16 | 16 | `checksum_body` | u128 | Body seal. Not used on client-facing 
frames; send zero. |
-| 32 | 16 | `cluster` | u128 | Cluster id. Send zero. |
-| 48 | 4 | `size` | u32 | Total frame length: 256 + body length. |
-| 52 | 4 | `view` | u32 | Consensus view. Send zero. |
-| 56 | 4 | `release` | u32 | Must be zero. |
-| 60 | 1 | `command` | u8 | `5` (`Request`). |
-| 61 | 1 | `replica` | u8 | Send zero. |
-| 62 | 66 | reserved | bytes | Zero. |
-| 128 | 16 | `client` | u128 | Client-chosen session identity, non-zero. 
Minted fresh for each registration. |
-| 144 | 16 | `request_checksum` | u128 | Optional integrity stamp over the 
request body (the Rust SDK uses XxHash3-64 widened to u128). Lets the server's 
client table catch a `request` number reused for different arguments. Zero 
disables the comparison. Stamped only for metadata-plane operations; zero for 
partition-plane and non-replicated ones. |
-| 160 | 8 | `timestamp` | u64 | Informational; the server echoes it into 
`ReplyHeader.timestamp`. May be zero. |
-| 168 | 8 | `request` | u64 | Request number, per client. See [request 
numbering](#request-numbering). |
-| 176 | 1 | `operation` | u8 | The [`Operation`](#operation-discriminants) 
discriminant. |
-| 177 | 7 | padding | bytes | Zero. |
-| 184 | 8 | `session` | u64 | Session fence epoch from the login reply. Zero 
on the login-register request itself and on non-replicated operations sent 
before login (only `PING` is accepted unauthenticated). |
-| 192 | 4 | `user_id` | u32 | Ignored on the wire; the server stamps the 
authenticated user itself. Send zero. |
-| 196 | 60 | reserved | bytes | Zero, except: for `operation = 2` 
(`NonReplicated`) bytes 196..200 carry the u32 command code, little-endian. |
-
-### Operation discriminants
-
-The `operation` byte tells the server which state-machine operation the 
request carries. Replicated operations are identified by `operation` alone. 
Non-replicated operations (reads, ping) all use `operation = 2` and carry their 
concrete u32 command code in header bytes 196..200.
-
-Values a client may send:
-
-| Value | Operation | Plane |
-|-------|-----------|-------|
-| 1 | `Register` | session (login handshake, codes 40 and 45) |
-| 2 | `NonReplicated` | reads and ping; the u32 code rides bytes 196..200 |
-| 3 | `Logout` | session (code 39) |
-| 128 | `CreateStream` | metadata |
-| 129 | `UpdateStream` | metadata |
-| 130 | `DeleteStream` | metadata |
-| 131 | `PurgeStream` | metadata |
-| 132 | `CreateTopic` | metadata |
-| 133 | `UpdateTopic` | metadata |
-| 134 | `DeleteTopic` | metadata |
-| 135 | `PurgeTopic` | metadata |
-| 136 | `CreatePartitions` | metadata |
-| 137 | `DeletePartitions` | metadata |
-| 138 | `DeleteSegments` | metadata (resolved to an internal partition 
truncation) |
-| 139 | `CreateConsumerGroup` | metadata |
-| 140 | `DeleteConsumerGroup` | metadata |
-| 141 | `CreateUser` | metadata |
-| 142 | `UpdateUser` | metadata |
-| 143 | `DeleteUser` | metadata |
-| 144 | `ChangePassword` | metadata |
-| 145 | `UpdatePermissions` | metadata |
-| 146 | `CreatePersonalAccessToken` | metadata |
-| 147 | `DeletePersonalAccessToken` | metadata |
-| 148 | `JoinConsumerGroup` | metadata |
-| 149 | `LeaveConsumerGroup` | metadata |
-| 160 | `SendMessages` | partition |
-| 161 | `StoreConsumerOffset` | partition |
-| 162 | `DeleteConsumerOffset` | partition |
-
-Value 0 is reserved and rejected. The 64..127 range is reserved for 
server-internal operations (currently 64 through 68 are assigned) and every 
value in it is refused from clients.
-
-The planes matter for delivery semantics:
-
-- **Metadata operations** replicate through the metadata consensus group. The 
server deduplicates them by `(client, request)` and caches replies, so a 
retried request gets the cached answer instead of a double apply (exactly-once).
-- **Partition operations** replicate through their partition's consensus 
group. They are at-least-once: no reply cache, a replay may apply again.
-- **Non-replicated operations** are reads. They bypass consensus and 
deduplication entirely.
-
-### Request numbering
-
-`request` is a per-client counter the server's client table tracks for 
metadata-plane operations:
-
-- Metadata operations must send a strictly increasing `request` (the SDK 
advances the counter per metadata request).
-- Partition operations and non-replicated operations send the current counter 
value without advancing it. The server doesn't track theirs.
-- The counter is a watermark, not a contiguous sequence: any value above the 
last accepted one is admissible.
-
-`session` is the fence epoch: the value handed back by the login reply. Every 
request after login must echo it. When the same `client` id registers again, 
the new registration mints a higher epoch and requests carrying the old one are 
fenced (rejected as zombies).
-
-### ReplyHeader
-
-Server to client. 256 bytes, followed by `size - 256` bytes of body.
-
-| Offset | Size | Field | Type | Description |
-|--------|------|-------|------|-------------|
-| 0 | 16 | `checksum` | u128 | Zero on client-facing frames. |
-| 16 | 16 | `checksum_body` | u128 | Zero on client-facing frames. |
-| 32 | 16 | `cluster` | u128 | Cluster id. |
-| 48 | 4 | `size` | u32 | Total frame length: 256 + body length. |
-| 52 | 4 | `view` | u32 | Consensus view the reply was produced in. |
-| 56 | 4 | `release` | u32 | Zero. |
-| 60 | 1 | `command` | u8 | `8` (`Reply`). |
-| 61 | 1 | `replica` | u8 | Answering replica index. |
-| 62 | 66 | reserved | bytes | Zero. |
-| 128 | 16 | `request_checksum` | u128 | Echoed from the request. |
-| 144 | 16 | `context` | u128 | Server context. |
-| 160 | 16 | `client` | u128 | Echoed client id. |
-| 176 | 8 | `op` | u64 | Log position of the committed operation. |
-| 184 | 8 | `commit` | u64 | Commit point at reply time. |
-| 192 | 8 | `timestamp` | u64 | Echo of the request `timestamp`. |
-| 200 | 8 | `request` | u64 | Echoed request number; correlate replies by 
this. |
-| 208 | 1 | `operation` | u8 | Echoed operation discriminant. |
-| 209 | 7 | padding | bytes | Zero. |
-| 216 | 4 | `status` | u32 | `0` = accepted. Nonzero = an `IggyError` code for 
a failure decided **before** commit (authorization denial, admission reject). A 
nonzero status always comes with an empty body. |
-| 220 | 36 | reserved | bytes | Zero. |
-
-Decode order for a client:
-
-1. Read 256 bytes, check `command`. `13` (`Eviction`) means the session is 
dead: map the eviction reason to an error and stop. `8` (`Reply`) continues.
-2. Read the remaining `size - 256` body bytes.
-3. If `status` is nonzero, the request failed pre-commit. The status value is 
the `IggyError` code and the body is empty.
-4. Otherwise decode the body. For result-framed operations, strip the [result 
section](#result-section) first.
-
-### Result section
-
-Replies for all metadata operations and for the partition-plane 
consumer-offset writes (`StoreConsumerOffset`, `DeleteConsumerOffset`) are 
**result-framed**: the body starts with a committed-result section ahead of the 
typed payload.
-
-```text
-[count: u32]
-count x { index: u32, result: u32 }
-```
-
-- Success: `count = 0`, and the typed response payload (if any) follows the 4 
count bytes.
-- Committed business rejection: one entry `{ index: 0, result: error_code }` 
and no payload.
-
-The header `status` channel and the result section are mutually exclusive by 
construction: a reply either failed pre-commit (`status` nonzero, empty body) 
or committed (`status` zero, result section present). A login-register reply 
carries the result section only when non-empty. On success its body starts 
directly with the [login response payload](#login-register).
-
-Replies to non-replicated commands aren't result-framed: after `status = 0` 
the body is the response payload directly.
-
-### EvictionHeader
-
-Server to client. 256 bytes, never a body. An eviction is session-terminal: it 
says "this session is dead", carries no per-request correlation, and a client 
should deinitialize and re-login.
-
-| Offset | Size | Field | Type | Description |
-|--------|------|-------|------|-------------|
-| 0 | 16 | `checksum` | u128 | Zero on client-facing frames. |
-| 16 | 16 | `checksum_body` | u128 | Zero on client-facing frames. |
-| 32 | 16 | `cluster` | u128 | Cluster id. |
-| 48 | 4 | `size` | u32 | Always 256: an eviction has no body. |
-| 52 | 4 | `view` | u32 | |
-| 56 | 4 | `release` | u32 | Zero. |
-| 60 | 1 | `command` | u8 | `13` (`Eviction`). |
-| 61 | 1 | `replica` | u8 | |
-| 62 | 66 | reserved | bytes | Zero. |
-| 128 | 16 | `client` | u128 | The evicted client id. |
-| 144 | 4 | `server_protocol_version` | u32 | Accepted protocol window (max), 
packed semver. Set only for reason 14, zero otherwise. |
-| 148 | 4 | `server_protocol_version_min` | u32 | Accepted protocol window 
(min). Set only for reason 14, zero otherwise. |
-| 152 | 103 | reserved | bytes | Zero. |
-| 255 | 1 | `reason` | u8 | Eviction reason, see below. |
-
-Eviction reasons:
-
-| Value | Reason | Meaning |
-|-------|--------|---------|
-| 1 | `NoSession` | No session exists for this client id |
-| 2 | `ClientReleaseTooLow` | Client release below cluster minimum |
-| 3 | `ClientReleaseTooHigh` | Client release above cluster maximum |
-| 4 | `InvalidRequestOperation` | Unknown `operation` discriminant |
-| 5 | `InvalidRequestBody` | Body failed validation |
-| 6 | `InvalidRequestBodySize` | Body size mismatch |
-| 7 | `SessionTooLow` | Session epoch below the cluster's retained minimum |
-| 8 | `SessionReleaseMismatch` | Session bound to a different release |
-| 9 | `InvalidCredentials` | Login refused: bad username or password |
-| 10 | `InvalidToken` | Login refused: bad personal access token |
-| 11 | `UserInactive` | Login refused: user inactive |
-| 12 | `SessionError` | Session-level failure |
-| 13 | `StaleClient` | Missed heartbeats; server evicted the session |
-| 14 | `IncompatibleProtocol` | Client protocol version outside the accepted 
window (see bytes 144..152) |
-| 15 | `MalformedLogin` | Login body without a decodable version prefix, or a 
legacy login code |
-
-Value 0 is reserved and never sent.
-
-## Connection lifecycle
-
-### Protocol version
-
-The protocol version is a semver packed into one u32, 10 bits per component 
(each must be below 1024):
-
-```text
-bits 31..30  reserved (zero)
-bits 29..20  major
-bits 19..10  minor
-bits  9..0   patch
-value = major << 20 | minor << 10 | patch
-```
-
-Integer order equals semver order. The value tracks the `iggy_binary_protocol` 
crate release. Under 0.x the compatibility gate is minor-scoped: the server 
accepts a client whose packed version is at least the server's minimum and 
whose `major.minor` is at most the server's. Patch releases never change the 
wire, so the upper bound ignores patch. Past 1.0.0 the gate follows strict 
semver (major bump = incompatible).
-
-### Login-register
-
-The only way to authenticate is the register handshake: command code **40** 
(`LOGIN_REGISTER`, username and password) or **45** (`LOGIN_REGISTER_WITH_PAT`, 
personal access token). Both ride `operation = 1` (`Register`) with `session = 
0` and a freshly minted non-zero `client` id.
-
-Both request bodies begin with the `ClientVersionInfo` prefix, so the server 
can gate on the version before touching credentials:
-
-```text
-[protocol_version: u32]
-[sdk_name_len: u8][sdk_name: UTF-8, 1-255 bytes]
-[sdk_version_len: u8][sdk_version: UTF-8, 1-255 bytes]
-```
-
-`protocol_version` is the packed version of the protocol the client was built 
against. `sdk_name` identifies the SDK (for example `rust-sdk`, `go-sdk`). 
`sdk_version` is the SDK's own build version.
-
-After the prefix:
-
-**`LOGIN_REGISTER` (code 40):**
-
-```text
-[ClientVersionInfo]
-[username_len: u8][username: N]
-[password_len: u8][password: N]
-[context_len: u32][context: N, only when context_len > 0]
-```
-
-**`LOGIN_REGISTER_WITH_PAT` (code 45):**
-
-```text
-[ClientVersionInfo]
-[token_len: u8][token: N]
-[context_len: u32][context: N, only when context_len > 0]
-```
-
-An incompatible protocol version is answered with a 256-byte `Eviction` frame, 
reason `14` (`IncompatibleProtocol`), carrying the accepted window at bytes 144 
(max) and 148 (min) as packed u32 versions. A body without a decodable 
`ClientVersionInfo` prefix gets reason `15` (`MalformedLogin`) with a zero 
window. Bad credentials get reasons 9-11.
-
-A successful login is a normal `Reply` whose body is:
-
-```text
-[user_id: u32]
-[session: u64]
-[server_protocol_version: u32]
-[server_version_len: u8][server_version: N]
-```
-
-The client stores `session` and echoes it in every subsequent request header. 
Logout is command code **39** (`LOGOUT_USER`), `operation = 3` (`Logout`), 
empty body.
-
-The legacy login commands **38** (`LOGIN_USER`) and **44** 
(`LOGIN_WITH_PERSONAL_ACCESS_TOKEN`) are refused: the server answers them with 
an `Eviction` frame, reason `15` (`MalformedLogin`). Only `PING` is accepted 
before login. Every other command on an unauthenticated connection is denied.
-
-## Command catalog
-
-The u32 command codes. Replicated commands are identified on the wire by their 
`operation` byte (see [Operation discriminants](#operation-discriminants)). The 
code column below is the protocol-level registry and, for non-replicated 
commands, the value carried in header bytes 196..200.
-
-```bash
-# System
-PING = 1                                  # non-replicated; works without login
-GET_STATS = 10                            # non-replicated
-GET_SNAPSHOT = 11                         # non-replicated
-GET_CLUSTER_METADATA = 12                 # non-replicated
-DESCRIBE_OPTIONS = 13                     # non-replicated
-GET_ME = 20                               # non-replicated
-GET_CLIENT = 21                           # non-replicated
-GET_CLIENTS = 22                          # non-replicated
-
-# Users
-GET_USER = 31                             # non-replicated
-GET_USERS = 32                            # non-replicated
-CREATE_USER = 33                          # operation 141
-DELETE_USER = 34                          # operation 143
-UPDATE_USER = 35                          # operation 142
-UPDATE_PERMISSIONS = 36                   # operation 145
-CHANGE_PASSWORD = 37                      # operation 144
-LOGIN_USER = 38                           # legacy; server rejects with 
MalformedLogin eviction
-LOGOUT_USER = 39                          # operation 3 (Logout)
-LOGIN_REGISTER = 40                       # operation 1 (Register)
-LOGIN_REGISTER_WITH_PAT = 45              # operation 1 (Register)
-
-# Personal access tokens
-GET_PERSONAL_ACCESS_TOKENS = 41           # non-replicated
-CREATE_PERSONAL_ACCESS_TOKEN = 42         # operation 146
-DELETE_PERSONAL_ACCESS_TOKEN = 43         # operation 147
-LOGIN_WITH_PERSONAL_ACCESS_TOKEN = 44     # legacy; server rejects with 
MalformedLogin eviction
-
-# Messages
-POLL_MESSAGES = 100                       # non-replicated
-SEND_MESSAGES = 101                       # operation 160
-FLUSH_UNSAVED_BUFFER = 102                # parses, but always fails with 
FeatureUnavailable
-
-# Consumer offsets
-GET_CONSUMER_OFFSET = 120                 # non-replicated
-STORE_CONSUMER_OFFSET = 121               # operation 161
-DELETE_CONSUMER_OFFSET = 122              # operation 162
-
-# Streams
-GET_STREAM = 200                          # non-replicated
-GET_STREAMS = 201                         # non-replicated
-CREATE_STREAM = 202                       # operation 128
-DELETE_STREAM = 203                       # operation 130
-UPDATE_STREAM = 204                       # operation 129
-PURGE_STREAM = 205                        # operation 131
-
-# Topics
-GET_TOPIC = 300                           # non-replicated
-GET_TOPICS = 301                          # non-replicated
-CREATE_TOPIC = 302                        # operation 132
-DELETE_TOPIC = 303                        # operation 134
-UPDATE_TOPIC = 304                        # operation 133
-PURGE_TOPIC = 305                         # operation 135
-
-# Partitions
-CREATE_PARTITIONS = 402                   # operation 136
-DELETE_PARTITIONS = 403                   # operation 137
-
-# Segments
-DELETE_SEGMENTS = 503                     # operation 138
-
-# Consumer groups
-GET_CONSUMER_GROUP = 600                  # non-replicated
-GET_CONSUMER_GROUPS = 601                 # non-replicated
-CREATE_CONSUMER_GROUP = 602               # operation 139
-DELETE_CONSUMER_GROUP = 603               # operation 140
-JOIN_CONSUMER_GROUP = 604                 # operation 148
-LEAVE_CONSUMER_GROUP = 605                # operation 149
-SYNC_CONSUMER_GROUP = 606                 # non-replicated
-```
-
-`FLUSH_UNSAVED_BUFFER` still decodes on the wire, but the server has no 
on-demand flush primitive and answers every call with `FeatureUnavailable`. 
Per-topic durability is configured with the `enforce_fsync` [topic 
option](/docs/server/topic-options) instead.
-
-## Shared encodings
-
-### Name
-
-Length-prefixed UTF-8 string, used for stream, topic, group, user, and token 
names:
-
-```text
-[length: u8][value: UTF-8, 1-255 bytes]
-```
-
-### Identifier
-
-Polymorphic identifier of a stream, topic, user, or consumer group:
-
-```text
-[kind: u8][length: u8][value: N bytes]
-```
-
-- Numeric: `kind = 1`, `length = 4`, value is a u32.
-- String: `kind = 2`, `length = 1..255`, value is UTF-8 bytes.
-
-### Consumer
-
-Identifies a consumer or a consumer group in message and offset commands:
-
-```text
-[kind: u8][id: Identifier]
-```
-
-- `kind = 1`: consumer.
-- `kind = 2`: consumer group.
-
-### Optional partition id
-
-Several commands take an optional partition id encoded as 5 fixed bytes:
-
-```text
-[flag: u8][partition_id: u32]
-```
-
-`flag = 1` means the id is set. `flag = 0` means none (the value bytes are 
then zero). For consumer-group consumers the partition id is left unset and the 
server resolves it.
-
-### Partitioning
-
-How `SendMessages` picks the target partition:
-
-```text
-[kind: u8][length: u8][value: 0-255 bytes]
-```
-
-- `Balanced`: `kind = 1`, `length = 0`, no value. The server resolves the 
partition at admission with a per-node round-robin counter.
-- `PartitionId`: `kind = 2`, `length = 4`, value is a u32 partition id.
-- `MessagesKey`: `kind = 3`, `length = 1..255`, value is a routing key hashed 
to a partition.
-
-First-party binary SDKs pre-resolve balanced and key routing client-side and 
normally send `kind = 2`.
-
-### Polling strategy
-
-Fixed 9 bytes:
-
-```text
-[kind: u8][value: u64]
-```
-
-| Kind | Name | Meaning of `value` |
-|------|------|--------------------|
-| 1 | Offset | Start at this offset |
-| 2 | Timestamp | Start at this timestamp (microseconds) |
-| 3 | First | Start from the beginning (`value` ignored) |
-| 4 | Last | Start from the end (`value` ignored) |
-| 5 | Next | Continue after the stored consumer offset (`value` ignored) |
-
-### Header kinds
-
-Typed values used by user headers and option values. The kind byte:
-
-| Code | Kind | Code | Kind |
-|------|------|------|------|
-| 1 | Raw | 9 | Uint8 |
-| 2 | String | 10 | Uint16 |
-| 3 | Bool | 11 | Uint32 |
-| 4 | Int8 | 12 | Uint64 |
-| 5 | Int16 | 13 | Uint128 |
-| 6 | Int32 | 14 | Float32 |
-| 7 | Int64 | 15 | Float64 |
-
-Integers are little-endian. `Bool` is one byte.
-
-### User headers block
-
-A user headers block (inside a [message frame](#message-batch-format)) is a 
flat run of TLV fields:
-
-```text
-[kind: u8][length: u32][data: length bytes]
-```
-
-Fields pair up: first the key, then the value. Keys are `String` kind. Every 
`length` must be 1..=255, kind 0 is rejected, and the block must consume its 
byte range exactly. Unknown value kind codes are preserved and forwarded, so 
headers survive mixed-version clusters.
-
-### Options block
-
-`CreateStream`, `UpdateStream`, `CreateTopic`, `UpdateTopic`, `CreateUser`, 
and `UpdateUser` end with a key-value **options block** that reuses the 
user-headers TLV encoding. The block runs from its start to the end of the 
payload (no length prefix, and the validator requires exact consumption) and 
may be empty.
-
-On top of the TLV walk, options enforce: string keys only (kind 2, valid 
UTF-8), no duplicate keys, at most 1024 entries, and at most 100000 bytes total.
-
-Semantics:
-
-- **Create** requests resolve absent keys to server defaults and persist the 
effective values, so `GetTopic` always shows what is in force.
-- **Update** requests are patches: keys absent from the block are left alone, 
never reset. A client built before a key existed cannot erase it.
-- Unknown keys are rejected at the wire edge, never silently skipped.
-
-The catalog is discoverable at runtime with `DESCRIBE_OPTIONS` (code 13), 
payload `[scope: u8]` with scope `1` = topic, `2` = stream, `3` = user (HTTP: 
`GET /options/topic`). Today only topics have keys. The stream and user 
catalogs are empty, and any key sent for them is rejected. The topic catalog 
(`segment_size`, `enforce_fsync`, `message_expiry`, `max_topic_size`, and the 
rest) with defaults and constraints is documented on the [Topic 
options](/docs/server/topic-options) page. `Updat [...]
-
-### Compression
-
-The `compression_algorithm` value used by topic options:
-
-| Code / name | Algorithm |
-|-------------|-----------|
-| 1, `none` | No compression (default) |
-| 2, `gzip` | Gzip |
-
-Other algorithms (lz4, zstd) are planned but not implemented. Any other value 
is rejected.
-
-## Message batch format
-
-Messages travel and rest in exactly one layout, the **batch**: the 
`SendMessages` body, the replicated record, the persisted segment record, and 
the poll reply all share it. There is no other message encoding.
-
-```text
-[batch header: 256 bytes][blob: message frames]
-frame = [frame header: 48 bytes][payload][user_headers]
-```
-
-### Batch header (256 bytes)
-
-| Offset | Size | Field | Type | Description |
-|--------|------|-------|------|-------------|
-| 0 | 8 | `partition_id` | u64 | Server-stamped at ingestion. Producers send 
zero. |
-| 8 | 8 | `base_offset` | u64 | Server-stamped at persistence. Producers send 
zero. |
-| 16 | 8 | `base_timestamp` | u64 | Server-stamped at persistence 
(microseconds). Producers send zero. |
-| 24 | 8 | `origin_timestamp` | u64 | Producer-set: the earliest 
`origin_timestamp` among the batch's messages (microseconds). |
-| 32 | 8 | `batch_length` | u64 | Total batch size: 256 + blob length. |
-| 40 | 8 | `batch_checksum` | u64 | XxHash3-64 over the header fields 
(`partition_id`, `base_offset`, `base_timestamp`, `origin_timestamp`, 
`batch_length`, `message_count`, in that order, little-endian bytes) followed 
by each frame's 8 checksum bytes. Recomputed by the server when it stamps the 
header. |
-| 48 | 4 | `message_count` | u32 | Number of frames in the blob. |
-| 52 | 204 | reserved | bytes | Must be zero; the server rejects a batch with 
nonzero reserved bytes. |
-
-### Message frame header (48 bytes)
-
-| Offset | Size | Field | Type | Description |
-|--------|------|-------|------|-------------|
-| 0 | 8 | `checksum` | u64 | XxHash3-64 over frame bytes 8..48 followed by 
`payload` and `user_headers`. Producer-computed; verified at admission. |
-| 8 | 16 | `id` | u128 | Message id (for example a UUID). |
-| 24 | 4 | `offset_delta` | u32 | Message offset = `base_offset + 
offset_delta`. Producer sends the frame's index in the batch. |
-| 28 | 4 | `timestamp_delta` | u32 | Microsecond delta against the batch 
`origin_timestamp`. One batch spans at most about 71.6 minutes of producer 
clock (`u32` microseconds). |
-| 32 | 4 | `user_headers_length` | u32 | Byte length of the user headers 
block. |
-| 36 | 4 | `payload_length` | u32 | Byte length of the payload. |
-| 40 | 8 | reserved | u64 | Must be zero. |
-
-After the 48 header bytes: `payload` (`payload_length` bytes), then 
`user_headers` (`user_headers_length` bytes). The payload comes **first**.
-
-A frame's absolute position is derived, not stored: offset = `base_offset + 
offset_delta`, timestamp = `base_timestamp + timestamp_delta` for the 
server-stamped time (`origin_timestamp + timestamp_delta` for producer time).
-
-The 64-byte `IggyMessageHeader` (checksum, id, offset, timestamp, 
origin_timestamp, lengths, reserved) that earlier protocol generations put in 
front of every message **no longer exists on the wire or on disk**. It survives 
only as an in-memory model inside the SDKs.
-
-## Command payloads
-
-Payloads below are the request **body** (the bytes after the 256-byte header). 
Types reference the [shared encodings](#shared-encodings). Empty payload means 
the body has zero bytes.
-
-### System
-
-**Ping. Code: 1.** Empty payload. The only command accepted before login.
-
-**Get stats. Code: 10.** Empty payload.
-
-**Get snapshot. Code: 11.**
-
-```text
-[compression: u8][types_count: u8][snapshot_type: u8] x types_count
-```
-
-Compression codes: 1 = Stored, 2 = Deflated, 3 = Bzip2, 4 = Zstd, 5 = Lzma, 6 
= Xz. Snapshot type codes: 1 = FilesystemOverview, 2 = ProcessList, 3 = 
ResourceUsage, 4 = Test, 5 = ServerLogs, 6 = ServerConfig, 100 = All.
-
-**Get cluster metadata. Code: 12.** Empty payload.
-
-**Describe options. Code: 13.**
-
-```text
-[scope: u8]
-```
-
-Scope: 1 = topic, 2 = stream, 3 = user. Returns the option catalog for the 
scope: keys, value kinds, defaults, and descriptions. See [Options 
block](#options-block).
-
-**Get me. Code: 20.** Empty payload.
-
-**Get client. Code: 21.**
-
-```text
-[client_id: u32]
-```
-
-**Get clients. Code: 22.** Empty payload.
-
-### Streams
-
-**Get stream. Code: 200.**
-
-```text
-[stream_id: Identifier]
-```
-
-**Get streams. Code: 201.** Empty payload.
-
-**Create stream. Code: 202.**
-
-```text
-[name_len: u8][name: N][options block to end]
-```
-
-The stream option catalog is empty today, so the block is normally empty (zero 
bytes).
-
-**Delete stream. Code: 203.**
-
-```text
-[stream_id: Identifier]
-```
-
-**Update stream. Code: 204.**
-
-```text
-[stream_id: Identifier][name_len: u8][name: N][options block to end]
-```
-
-Patch semantics: absent option keys are left unchanged.
-
-**Purge stream. Code: 205.**
-
-```text
-[stream_id: Identifier]
-```
-
-### Topics
-
-**Get topic. Code: 300.**
-
-```text
-[stream_id: Identifier][topic_id: Identifier]
-```
-
-**Get topics. Code: 301.**
-
-```text
-[stream_id: Identifier]
-```
-
-**Create topic. Code: 302.**
-
-```text
-[stream_id: Identifier][partitions_count: u32][name_len: u8][name: N][options 
block to end]
-```
-
-The fixed fields are the shape of the operation: which stream, how many 
partitions, what name. Every topic setting (`compression_algorithm`, 
`message_expiry`, `max_topic_size`, `segment_size`, `enforce_fsync`, 
`messages_required_to_save`, `size_of_messages_required_to_save`, 
`preallocate_segments`) rides the options block. See [Topic 
options](/docs/server/topic-options). `partitions_count` is an argument, not a 
setting: it is consumed at admission and never persisted as an option.
-
-**Delete topic. Code: 303.**
-
-```text
-[stream_id: Identifier][topic_id: Identifier]
-```
-
-**Update topic. Code: 304.**
-
-```text
-[stream_id: Identifier][topic_id: Identifier][name_len: u8][name: N][options 
block to end]
-```
-
-Only `compression_algorithm`, `message_expiry`, and `max_topic_size` are 
updatable. Absent keys are left unchanged.
-
-**Purge topic. Code: 305.**
-
-```text
-[stream_id: Identifier][topic_id: Identifier]
-```
-
-### Partitions
-
-**Create partitions. Code: 402.**
-
-```text
-[stream_id: Identifier][topic_id: Identifier][partitions_count: u32]
-```
-
-**Delete partitions. Code: 403.**
-
-```text
-[stream_id: Identifier][topic_id: Identifier][partitions_count: u32]
-```
-
-### Segments
-
-**Delete segments. Code: 503.**
-
-```text
-[stream_id: Identifier][topic_id: Identifier][partition_id: 
u32][segments_count: u32]
-```
-
-Deletes the `segments_count` oldest sealed segments of the partition.
-
-### Messages
-
-**Poll messages. Code: 100.**
-
-```text
-[consumer: Consumer][stream_id: Identifier][topic_id: Identifier]
-[partition_flag: u8][partition_id: u32]
-[strategy: 9 bytes][count: u32][auto_commit: u8]
-```
-
-`strategy` is a [polling strategy](#polling-strategy). `count` is the 
requested number of messages. `auto_commit = 1` stores the consumer offset 
server-side as part of the poll.
-
-The response body:
-
-```text
-[partition_id: u32][current_offset: u64][messages_count: u32]
-[batch records to end]
-```
-
-The 16-byte prefix is followed by a stream of [batch 
records](#message-batch-format) served as stored: each record's header carries 
the stamped `base_offset` and `base_timestamp`, and each frame's deltas resolve 
against them. A record may be a server-sliced view of a larger stored batch, so 
the first polled offset is `base_offset + offset_delta` of the first frame, not 
necessarily `base_offset` itself. `current_offset` is the partition's newest 
offset at poll time.
-
-**Send messages. Code: 101.**
-
-```text
-[metadata_length: u32]
-[stream_id: Identifier][topic_id: Identifier][partitioning: Partitioning]
-[messages_count: u32]
-[batch: 256-byte batch header + frames]
-```
-
-`metadata_length` counts the bytes from `stream_id` through `messages_count` 
inclusive, so a reader can skip straight to the batch. The producer leaves 
`partition_id`, `base_offset`, and `base_timestamp` zero in the batch header, 
and the server stamps them. Every checksum is producer-computed and verified at 
admission. The reserved regions must be zero.
-
-**Flush unsaved buffer. Code: 102.**
-
-```text
-[stream_id: Identifier][topic_id: Identifier][partition_id: u32][fsync: u8]
-```
-
-Parses, but the server always answers `FeatureUnavailable`: there is no 
on-demand flush primitive. Use the per-topic `enforce_fsync` option for 
durability guarantees.
-
-### Consumer offsets
-
-**Get consumer offset. Code: 120.**
-
-```text
-[consumer: Consumer][stream_id: Identifier][topic_id: Identifier]
-[partition_flag: u8][partition_id: u32]
-```
-
-Response body (20 bytes):
-
-```text
-[partition_id: u32][current_offset: u64][stored_offset: u64]
-```
-
-**Store consumer offset. Code: 121.**
-
-```text
-[consumer: Consumer][stream_id: Identifier][topic_id: Identifier]
-[partition_flag: u8][partition_id: u32][offset: u64][ack: u8]
-```
-
-**Delete consumer offset. Code: 122.**
-
-```text
-[consumer: Consumer][stream_id: Identifier][topic_id: Identifier]
-[partition_flag: u8][partition_id: u32][ack: u8]
-```
-
-The trailing `ack` byte is mandatory on both write commands. A payload without 
it fails to decode. Ack levels:
-
-| Value | Level | Meaning |
-|-------|-------|---------|
-| 0 | `NoAck` | Leader-local write; respond as soon as local state is updated 
(the fast path `PollMessages` auto-commit uses) |
-| 1 | `Quorum` | Replicate through partition consensus; respond after a quorum 
commit (default for explicit writes) |
-
-### Consumer groups
-
-**Get consumer group. Code: 600.**
-
-```text
-[stream_id: Identifier][topic_id: Identifier][group_id: Identifier]
-```
-
-**Get consumer groups. Code: 601.**
-
-```text
-[stream_id: Identifier][topic_id: Identifier]
-```
-
-**Create consumer group. Code: 602.**
-
-```text
-[stream_id: Identifier][topic_id: Identifier][name_len: u8][name: N]
-```
-
-**Delete consumer group. Code: 603.**
-
-```text
-[stream_id: Identifier][topic_id: Identifier][group_id: Identifier]
-```
-
-**Join consumer group. Code: 604.**
-
-```text
-[stream_id: Identifier][topic_id: Identifier][group_id: Identifier]
-```
-
-**Leave consumer group. Code: 605.**
-
-```text
-[stream_id: Identifier][topic_id: Identifier][group_id: Identifier]
-```
-
-**Sync consumer group. Code: 606.**
-
-```text
-[stream_id: Identifier][topic_id: Identifier][group_id: Identifier]
-```
-
-Read-only: a member asks for its current partition assignment and the group 
generation, so it can select partitions to poll client-side.
-
-### Users
-
-**Get user. Code: 31.**
-
-```text
-[user_id: Identifier]
-```
-
-**Get users. Code: 32.** Empty payload.
-
-**Create user. Code: 33.**
-
-```text
-[username_len: u8][username: N][password_len: u8][password: N][status: u8]
-[has_permissions: u8][permissions_len: u32, only when has_permissions = 
1][permissions: M bytes]
-[options block to end]
-```
-
-`permissions_len` and the permissions bytes are present **only** when 
`has_permissions = 1`. When it is 0 the options block follows immediately. The 
user option catalog is empty today, so the block is normally empty. `status`: 1 
= active, 2 = inactive.
-
-**Delete user. Code: 34.**
-
-```text
-[user_id: Identifier]
-```
-
-**Update user. Code: 35.**
-
-```text
-[user_id: Identifier]
-[has_username: u8][username_len: u8, only when 1][username: N, only when 1]
-[has_status: u8][status: u8, only when 1]
-[options block to end]
-```
-
-**Update permissions. Code: 36.**
-
-```text
-[user_id: Identifier][has_permissions: u8]
-[permissions_len: u32, only when has_permissions = 1][permissions: M bytes, 
only when 1]
-```
-
-As in `CreateUser`, the length field is conditional: with `has_permissions = 
0` the payload ends at the flag.
-
-**Change password. Code: 37.**
-
-```text
-[user_id: Identifier]
-[current_password_len: u8][current_password: N]
-[new_password_len: u8][new_password: N]
-```
-
-### Authentication
-
-Login and logout are part of the [connection lifecycle](#connection-lifecycle):
-
-- **Login register. Code: 40.** `Operation::Register`. The body is 
`ClientVersionInfo` + credentials, described [above](#login-register).
-- **Login register with PAT. Code: 45.** `Operation::Register`. The body is 
`ClientVersionInfo` + token.
-- **Logout user. Code: 39.** `Operation::Logout`. Empty payload.
-- Codes **38** and **44** are the pre-register login shapes. The server 
rejects both with a `MalformedLogin` eviction.
-
-### Personal access tokens
-
-**Get personal access tokens. Code: 41.** Empty payload.
-
-**Create personal access token. Code: 42.**
-
-```text
-[name_len: u8][name: N][expiry: u64]
-```
-
-`expiry` is a duration in **microseconds**. Two sentinel values exist: `0` 
(server default, which currently means no expiry) and `u64::MAX` (never 
expires).
-
-**Delete personal access token. Code: 43.**
-
-```text
-[name_len: u8][name: N]
-```
diff --git a/content/docs/server/storage-engine.mdx 
b/content/docs/server/storage-engine.mdx
index c56fb160..7d003434 100644
--- a/content/docs/server/storage-engine.mdx
+++ b/content/docs/server/storage-engine.mdx
@@ -59,9 +59,9 @@ Segments store **batch records**, not individual messages. 
The record is byte-id
 frame = [frame header: 48 bytes][payload][user_headers]
 ```
 
-The 256-byte batch header carries `partition_id`, `base_offset`, 
`base_timestamp`, `origin_timestamp`, `batch_length`, `batch_checksum` 
(XxHash3-64), and `message_count`. The rest is reserved and must be zero. Each 
48-byte frame header carries `checksum` (XxHash3-64), `id` (u128), 
`offset_delta` (u32), `timestamp_delta` (u32), and the two lengths. A message's 
absolute offset is `base_offset + offset_delta`, and timestamps resolve the 
same way against the base. All fields are **little-end [...]
+The 256-byte batch header carries `partition_id`, `base_offset`, 
`base_timestamp`, `origin_timestamp`, `batch_length`, `batch_checksum` 
(XxHash3-64), and `message_count`. The rest is reserved and must be zero. Each 
48-byte frame header carries `checksum` (XxHash3-64), `id` (u128), 
`offset_delta` (u32), `timestamp_delta` (u32), and the two lengths. A message's 
absolute offset is `base_offset + offset_delta`; its server timestamp is the 
flat `base_timestamp`, while `timestamp_delta` resolv [...]
 
-The full byte-level tables live on the 
[Schema](/docs/server/schema#message-batch-format) page. What lands on disk is 
exactly what the producer sent, with the server stamping `partition_id` at 
ingestion and `base_offset` / `base_timestamp` (plus a `batch_checksum` 
recompute) at persistence.
+The full byte-level tables live on the [Message 
batches](/docs/binary-protocol/messages) page. Absent at-rest encryption, what 
lands on disk is exactly what the producer sent, with the server stamping 
`partition_id` at admission and `base_offset` / `base_timestamp` (plus a 
`batch_checksum` recompute) at journal append.
 
 ## Indexes
 
@@ -79,36 +79,37 @@ There is no index caching configuration: the in-memory 
index cache is an interna
 
 ## Write pipeline
 
-Messages flow through admission, replication, and buffering before reaching a 
segment:
+Messages are admitted, stamped, and buffered in the partition journal, then 
replicated; committed batches reach a segment once a flush trigger fires:
 
 ```mermaid
 graph TD
   A["Client SendMessages: batch with producer-computed checksums"] --> 
B["Admission: verify checksums, stamp partition_id"]
-  B --> C["Partition consensus: prepare replicated to the partition's 
replicas"]
-  C --> D{"Quorum reached?"}
-  D -->|"Yes"| E["Commit: batch buffered in the partition journal"]
-  E --> F{"Flush threshold reached?"}
-  F -->|"count >= messages_required_to_save OR bytes >= 
size_of_messages_required_to_save"| G["Stamp base_offset / base_timestamp, 
recompute batch_checksum"]
-  F -->|"No"| E
-  G --> H["MessagesWriter: vectored write to .log + sparse index entry"]
-  H --> I{"enforce_fsync?"}
-  I -->|"Yes"| J["fsync"]
-  I -->|"No"| K["OS writeback"]
-  J --> L{"Segment past segment_size?"}
-  K --> L
-  L -->|"Yes"| M["Seal segment, open next"]
-  L -->|"No"| N["Done"]
+  B --> C["Stamp base_offset / base_timestamp, recompute batch_checksum"]
+  C --> D["Append to the in-memory partition journal"]
+  D --> E["Partition consensus: prepare replicated to the partition's 
replicas"]
+  E --> F{"Quorum reached?"}
+  F -->|"Yes"| G["Commit"]
+  G --> H{"Flush trigger reached?"}
+  H -->|"No"| W["Stay buffered until a later commit trips the gate"]
+  H -->|"Yes"| I["MessagesWriter: vectored write to .log + sparse index entry"]
+  I --> J{"enforce_fsync?"}
+  J -->|"Yes"| K["fdatasync .log + .index"]
+  J -->|"No"| L["OS writeback"]
+  K --> M{"Segment past segment_size?"}
+  L --> M
+  M -->|"Yes"| N["Seal segment, open next"]
+  M -->|"No"| O["Done"]
 ```
 
 The flush thresholds and fsync policy are **per-topic creation options** (they 
used to be server-wide config):
 
 - `messages_required_to_save` (default `1024`): count threshold.
 - `size_of_messages_required_to_save` (default `1 MiB`): byte threshold.
-- `enforce_fsync` (default `false`): fsync every segment write.
+- `enforce_fsync` (default `false`): fdatasync the `.log` and `.index` on 
every flush.
 
-Whichever threshold trips first flushes. These are soft limits: a flush writes 
whole batches, so the actual flushed count or size can overshoot. The 
`MessagesWriter` uses **vectored I/O** (`writev`) with up to `MAX_IOV_COUNT = 
1024` buffers per syscall, so many buffered batches land in one write.
+A third trigger is independent of these options: when buffered journal bytes 
reach the active segment's `segment_size`, the flush fires. Whichever trigger 
trips first flushes. These are soft limits: a flush writes whole batches, so 
the actual flushed count or size can overshoot. The gate is evaluated on commit 
events, so a sub-threshold batch stays buffered until a later commit trips it 
(shutdown flushes unconditionally). The `MessagesWriter` uses **vectored I/O** 
(`writev`) with up to ` [...]
 
-Every partition belongs to a consensus group (Viewstamped Replication), and 
writes are prepared and quorum-acknowledged before commit. At HEAD the 
partition-plane consensus journal is **in-memory**. Durability of message data 
comes from segment persistence and, in clusters, from the copies on other 
replicas. The metadata plane (below) has a durable on-disk journal.
+Every partition belongs to a consensus group (Viewstamped Replication), and 
writes are prepared and quorum-acknowledged before commit. Stamping happens at 
journal-append time, before replication acks: the primary stamps `base_offset` 
from the log position and `base_timestamp` from the prepare timestamp, forwards 
the stamped bytes verbatim, and each backup re-derives the expected pair and 
refuses a mismatch, so the batch (and its recomputed `batch_checksum`) is 
byte-identical across repli [...]
 
 ## Boot-time segment recovery
 
@@ -186,7 +187,7 @@ key = ""  # 32-byte base64-encoded key
 
 ## Compression
 
-Per-topic payload compression is set with the `compression_algorithm` topic 
option. Supported algorithms: `none` (default) and `gzip`. Other algorithms 
(lz4, zstd) are planned but **not implemented**.
+The `compression_algorithm` topic option accepts `none` (default) and `gzip`, 
but it is a **placeholder today**: the value is persisted and reported back, 
and no compression is applied anywhere - segments store payloads exactly as 
sent. To compress today, do it client-side and tag messages via user headers - 
see the [message headers 
examples](https://github.com/apache/iggy/tree/master/examples/rust/src/message-headers)
 in the Iggy repo.
 
 ## Removed and relocated settings
 
diff --git a/content/docs/server/topic-options.mdx 
b/content/docs/server/topic-options.mdx
index 9214b347..c1b1b36b 100644
--- a/content/docs/server/topic-options.mdx
+++ b/content/docs/server/topic-options.mdx
@@ -12,7 +12,7 @@ Options are key-value pairs sent with `CreateTopic`. Unknown 
keys are **rejected
 |--------|---------|-------------|-------------|
 | `max_topic_size` | unlimited | | Delete the oldest sealed segments once the 
topic grows past this size. |
 | `message_expiry` | none | | Delete sealed segments older than this. |
-| `compression_algorithm` | `none` | `none` or `gzip` | Payload compression. |
+| `compression_algorithm` | `none` | `none` or `gzip` | Placeholder: stored 
and reported, no compression applied yet. |
 | `segment_size` | 1 GiB | 512-byte multiple, at least 1 MiB, at most 1 GiB | 
Soft size limit per segment: a segment may close one whole batch past it. |
 | `enforce_fsync` | `false` | | fsync every write to this topic's partitions. |
 | `messages_required_to_save` | 1024 | non-zero, at most 16777216 | Flush the 
journal once it holds this many messages. |
diff --git a/public/docs/server/schema/index.html 
b/public/docs/server/schema/index.html
new file mode 100644
index 00000000..6429f748
--- /dev/null
+++ b/public/docs/server/schema/index.html
@@ -0,0 +1,31 @@
+<!--
+  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.
+-->
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <title>Moved: Binary Protocol</title>
+    <meta http-equiv="refresh" content="0; url=/docs/binary-protocol/" />
+    <link rel="canonical" href="/docs/binary-protocol/" />
+    <meta name="robots" content="noindex" />
+  </head>
+  <body>
+    <p>The Schema page moved to <a href="/docs/binary-protocol/">Binary 
Protocol</a>.</p>
+  </body>
+</html>
diff --git a/src/app/global.css b/src/app/global.css
index 34b74a55..ca6a2802 100644
--- a/src/app/global.css
+++ b/src/app/global.css
@@ -93,6 +93,14 @@ header#nd-subnav {
     background-color: var(--color-fd-background) !important;
 }
 
+/* The mobile TOC bar wrapper is sticky at z-10 with a transparent background,
+   so page content that also uses z-10 paints over it while scrolling under.
+   Lift it above content and make it opaque, matching the navbar above it. */
+div[data-toc-popover] {
+    z-index: 30;
+    background-color: var(--color-fd-background);
+}
+
 figure.shiki {
     border-color: var(--color-fd-border) !important;
     box-shadow: none !important;
diff --git a/src/components/architecture-diagrams.tsx 
b/src/components/architecture-diagrams.tsx
index c4d025aa..99e02330 100644
--- a/src/components/architecture-diagrams.tsx
+++ b/src/components/architecture-diagrams.tsx
@@ -1139,7 +1139,7 @@ export function DocsHero() {
       <div className="absolute inset-0 
bg-[radial-gradient(ellipse_at_top_right,var(--color-fd-primary)_0%,transparent_60%)]
 opacity-[0.07]" />
       <div className="absolute top-0 right-0 w-64 h-64 bg-fd-primary/5 
rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
 
-      <div className="relative z-10">
+      <div className="relative">
         <div className="flex flex-col items-center text-center mb-8">
           <Logo className="mb-5" />
           <h2 className="text-2xl md:text-3xl font-bold text-fd-foreground m-0 
mb-2">
diff --git a/src/components/mermaid.tsx b/src/components/mermaid.tsx
index 25400a2b..767c07e9 100644
--- a/src/components/mermaid.tsx
+++ b/src/components/mermaid.tsx
@@ -57,11 +57,14 @@ function MermaidSVG({ chart }: { chart: string }) {
     }),
   );
 
-  const scaled = svg
-    .replaceAll('font-size="13"', 'font-size="15"')
-    .replaceAll('font-size="12"', 'font-size="14"')
-    .replaceAll('font-size="11"', 'font-size="14"')
-    .replaceAll('font-size="10"', 'font-size="13"');
+  // Zoom the whole SVG for readability. Bumping font-size attributes instead
+  // would grow text after layout, overflowing node boxes sized for the
+  // original metrics and colliding adjacent nodes.
+  const scaled = svg.replace(
+    / width="([0-9.]+)" height="([0-9.]+)"/,
+    (_, width, height) =>
+      ` width="${Math.round(Number(width) * 1.15)}" 
height="${Math.round(Number(height) * 1.15)}"`,
+  );
 
   return (
     <div

Reply via email to