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